mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
tweaks related to did:plc, fix bluesky profile creation, update deploys to build locally then scp
This commit is contained in:
@@ -232,7 +232,7 @@ See `config-appview.example.yaml` and `config-hold.example.yaml` for all options
|
||||
**Hold DID recovery/migration (did:plc):**
|
||||
1. Back up `rotation.key` and DID string (from `did.txt` or plc.directory)
|
||||
2. Set `database.did_method: plc` and `database.did: "did:plc:..."` in config
|
||||
3. Provide `rotation_key_path` — signing key auto-generates if missing
|
||||
3. Provide `rotation_key` (multibase K-256 private key) — signing key auto-generates if missing
|
||||
4. On boot: `LoadOrCreateDID()` adopts the DID, `EnsurePLCCurrent()` auto-updates PLC directory if keys/URL changed
|
||||
5. Without rotation key: hold boots but logs warning about PLC mismatch
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(plcCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/hold"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var plcCmd = &cobra.Command{
|
||||
Use: "plc",
|
||||
Short: "PLC directory management commands",
|
||||
}
|
||||
|
||||
var plcConfigFile string
|
||||
|
||||
var plcAddRotationKeyCmd = &cobra.Command{
|
||||
Use: "add-rotation-key <multibase-key>",
|
||||
Short: "Add a rotation key to this hold's PLC identity",
|
||||
Long: `Add an additional rotation key to the hold's did:plc document.
|
||||
The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z').
|
||||
The hold's configured rotation key is used to sign the PLC update.
|
||||
|
||||
atcr-hold plc add-rotation-key --config config.yaml z...`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := hold.LoadConfig(plcConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Database.DIDMethod != "plc" {
|
||||
return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Resolve the hold's DID
|
||||
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
|
||||
DID: cfg.Database.DID,
|
||||
DIDMethod: cfg.Database.DIDMethod,
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
}
|
||||
|
||||
// Parse the rotation key from config (required for signing PLC updates)
|
||||
if cfg.Database.RotationKey == "" {
|
||||
return fmt.Errorf("database.rotation_key must be set to sign PLC updates")
|
||||
}
|
||||
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
|
||||
// Parse the new key to add (K-256 or P-256)
|
||||
newKey, err := atcrypto.ParsePrivateMultibase(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key argument: %w", err)
|
||||
}
|
||||
newKeyPub, err := newKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key from argument: %w", err)
|
||||
}
|
||||
newKeyDIDKey := newKeyPub.DIDKey()
|
||||
|
||||
// Load signing key for verification methods
|
||||
keyPath := cfg.Database.KeyPath
|
||||
if keyPath == "" {
|
||||
keyPath = cfg.Database.Path + "/signing.key"
|
||||
}
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Fetch current PLC state
|
||||
plcDirectoryURL := cfg.Database.PLCDirectoryURL
|
||||
if plcDirectoryURL == "" {
|
||||
plcDirectoryURL = "https://plc.directory"
|
||||
}
|
||||
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
|
||||
|
||||
opLog, err := client.OpLog(ctx, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch PLC op log: %w", err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return fmt.Errorf("empty op log for %s", holdDID)
|
||||
}
|
||||
|
||||
lastEntry := opLog[len(opLog)-1]
|
||||
lastOp := lastEntry.Regular
|
||||
if lastOp == nil {
|
||||
return fmt.Errorf("last PLC operation is not a regular op")
|
||||
}
|
||||
|
||||
// Check if key already present
|
||||
for _, k := range lastOp.RotationKeys {
|
||||
if k == newKeyDIDKey {
|
||||
fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build updated rotation keys: keep existing, append new
|
||||
rotationKeys := make([]string, len(lastOp.RotationKeys))
|
||||
copy(rotationKeys, lastOp.RotationKeys)
|
||||
rotationKeys = append(rotationKeys, newKeyDIDKey)
|
||||
|
||||
// Build update: preserve everything else from current state
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get signing public key: %w", err)
|
||||
}
|
||||
|
||||
prevCID := lastEntry.AsOperation().CID().String()
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: rotationKeys,
|
||||
VerificationMethods: map[string]string{
|
||||
"atproto": sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: lastOp.AlsoKnownAs,
|
||||
Services: lastOp.Services,
|
||||
Prev: &prevCID,
|
||||
}
|
||||
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return fmt.Errorf("failed to sign PLC update: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Submit(ctx, holdDID, op); err != nil {
|
||||
return fmt.Errorf("failed to submit PLC update: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Added rotation key to PLC identity",
|
||||
"did", holdDID,
|
||||
"new_key", newKeyDIDKey,
|
||||
"total_rotation_keys", len(rotationKeys),
|
||||
)
|
||||
fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file")
|
||||
|
||||
plcCmd.AddCommand(plcAddRotationKeyCmd)
|
||||
}
|
||||
+1
-1
@@ -111,7 +111,7 @@ func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), e
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKeyPath: cfg.Database.RotationKeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -59,6 +59,10 @@ registration:
|
||||
allow_all_crew: false
|
||||
# URL to fetch avatar image from during bootstrap.
|
||||
profile_avatar_url: https://atcr.io/web-app-manifest-192x192.png
|
||||
# Bluesky profile display name. Synced on every startup.
|
||||
profile_display_name: Cargo Hold
|
||||
# Bluesky profile description. Synced on every startup.
|
||||
profile_description: ahoy from the cargo hold
|
||||
# Post to Bluesky when users push images. Synced to captain record on startup.
|
||||
enable_bluesky_posts: false
|
||||
# Deployment region, auto-detected from cloud metadata or S3 config.
|
||||
@@ -75,8 +79,8 @@ database:
|
||||
did: ""
|
||||
# PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory
|
||||
plc_directory_url: https://plc.directory
|
||||
# Rotation key path for did:plc. Controls DID identity (separate from signing key). Defaults to {database.path}/rotation.key.
|
||||
rotation_key_path: ""
|
||||
# Rotation key for did:plc in multibase format (starting with 'z'). Generate with: goat key generate. Supports K-256 and P-256 curves. Controls DID identity (separate from signing key).
|
||||
rotation_key: ""
|
||||
# libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite.
|
||||
libsql_sync_url: ""
|
||||
# Auth token for libSQL sync. Required if libsql_sync_url is set.
|
||||
|
||||
@@ -36,9 +36,9 @@ var cloudInitTmpl string
|
||||
// values like client_name, owner_did, etc. are literal in the templates.
|
||||
type ConfigValues struct {
|
||||
// S3 / Object Storage
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
|
||||
@@ -112,7 +112,8 @@ func renderScannerServiceUnit(p scannerServiceUnitParams) (string, error) {
|
||||
}
|
||||
|
||||
// generateAppviewCloudInit generates the cloud-init user-data script for the appview server.
|
||||
func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) {
|
||||
// Sets up the OS, directories, config, and systemd unit. Binaries are deployed separately via SCP.
|
||||
func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues) (string, error) {
|
||||
naming := cfg.Naming()
|
||||
|
||||
configYAML, err := renderConfig(appviewConfigTmpl, vals)
|
||||
@@ -133,16 +134,12 @@ func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion st
|
||||
}
|
||||
|
||||
return generateCloudInit(cloudInitParams{
|
||||
GoVersion: goVersion,
|
||||
BinaryName: naming.Appview(),
|
||||
BuildCmd: "appview",
|
||||
ServiceUnit: serviceUnit,
|
||||
ConfigYAML: configYAML,
|
||||
ConfigPath: naming.AppviewConfigPath(),
|
||||
ServiceName: naming.Appview(),
|
||||
DataDir: naming.BasePath(),
|
||||
RepoURL: cfg.RepoURL,
|
||||
RepoBranch: cfg.RepoBranch,
|
||||
InstallDir: naming.InstallDir(),
|
||||
SystemUser: naming.SystemUser(),
|
||||
ConfigDir: naming.ConfigDir(),
|
||||
@@ -152,9 +149,9 @@ func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion st
|
||||
}
|
||||
|
||||
// generateHoldCloudInit generates the cloud-init user-data script for the hold server.
|
||||
// When withScanner is true, a second phase is appended that builds the scanner binary,
|
||||
// creates scanner data directories, and installs a scanner systemd service.
|
||||
func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string, withScanner bool) (string, error) {
|
||||
// When withScanner is true, a second phase is appended that creates scanner data
|
||||
// directories and installs a scanner systemd service. Binaries are deployed separately via SCP.
|
||||
func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, withScanner bool) (string, error) {
|
||||
naming := cfg.Naming()
|
||||
|
||||
configYAML, err := renderConfig(holdConfigTmpl, vals)
|
||||
@@ -175,16 +172,12 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin
|
||||
}
|
||||
|
||||
script, err := generateCloudInit(cloudInitParams{
|
||||
GoVersion: goVersion,
|
||||
BinaryName: naming.Hold(),
|
||||
BuildCmd: "hold",
|
||||
ServiceUnit: serviceUnit,
|
||||
ConfigYAML: configYAML,
|
||||
ConfigPath: naming.HoldConfigPath(),
|
||||
ServiceName: naming.Hold(),
|
||||
DataDir: naming.BasePath(),
|
||||
RepoURL: cfg.RepoURL,
|
||||
RepoBranch: cfg.RepoBranch,
|
||||
InstallDir: naming.InstallDir(),
|
||||
SystemUser: naming.SystemUser(),
|
||||
ConfigDir: naming.ConfigDir(),
|
||||
@@ -205,7 +198,7 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin
|
||||
return "", fmt.Errorf("scanner config: %w", err)
|
||||
}
|
||||
|
||||
// Append scanner build and setup phase
|
||||
// Append scanner setup phase (no build — binary deployed via SCP)
|
||||
scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{
|
||||
DisplayName: naming.DisplayName(),
|
||||
User: naming.SystemUser(),
|
||||
@@ -225,13 +218,6 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin
|
||||
|
||||
scannerPhase := fmt.Sprintf(`
|
||||
# === Scanner Setup ===
|
||||
echo "Building scanner..."
|
||||
cd %s/scanner
|
||||
CGO_ENABLED=1 go build \
|
||||
-ldflags="-s -w" \
|
||||
-trimpath \
|
||||
-o ../bin/%s ./cmd/scanner
|
||||
cd %s
|
||||
|
||||
# Scanner data dirs
|
||||
mkdir -p %s/vulndb %s/tmp
|
||||
@@ -251,9 +237,6 @@ systemctl enable %s
|
||||
|
||||
echo "=== Scanner setup complete ==="
|
||||
`,
|
||||
naming.InstallDir(),
|
||||
naming.Scanner(),
|
||||
naming.InstallDir(),
|
||||
naming.ScannerDataDir(), naming.ScannerDataDir(),
|
||||
naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir(),
|
||||
naming.ScannerConfigPath(),
|
||||
@@ -267,16 +250,12 @@ echo "=== Scanner setup complete ==="
|
||||
}
|
||||
|
||||
type cloudInitParams struct {
|
||||
GoVersion string
|
||||
BinaryName string
|
||||
BuildCmd string
|
||||
ServiceUnit string
|
||||
ConfigYAML string
|
||||
ConfigPath string
|
||||
ServiceName string
|
||||
DataDir string
|
||||
RepoURL string
|
||||
RepoBranch string
|
||||
InstallDir string
|
||||
SystemUser string
|
||||
ConfigDir string
|
||||
|
||||
@@ -19,32 +19,15 @@ export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update && apt-get upgrade -y
|
||||
apt-get install -y git gcc make curl libsqlite3-dev nodejs npm htop
|
||||
|
||||
# Swap (for builds on small instances)
|
||||
# Swap (for small instances)
|
||||
if [ ! -f /swapfile ]; then
|
||||
dd if=/dev/zero of=/swapfile bs=1M count=2048
|
||||
chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
|
||||
# Go {{.GoVersion}}
|
||||
curl -fsSL https://go.dev/dl/go{{.GoVersion}}.linux-amd64.tar.gz | tar -C /usr/local -xz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
export GOTMPDIR=/var/tmp
|
||||
|
||||
# Clone & build
|
||||
if [ -d {{.InstallDir}} ]; then
|
||||
cd {{.InstallDir}} && git pull origin {{.RepoBranch}}
|
||||
else
|
||||
git clone -b {{.RepoBranch}} {{.RepoURL}} {{.InstallDir}}
|
||||
cd {{.InstallDir}}
|
||||
fi
|
||||
npm ci
|
||||
go generate ./...
|
||||
CGO_ENABLED=1 go build \
|
||||
-ldflags="-s -w" \
|
||||
-trimpath \
|
||||
-o bin/{{.BinaryName}} ./cmd/{{.BuildCmd}}
|
||||
# Install directory (binaries deployed via SCP)
|
||||
mkdir -p {{.InstallDir}}/bin
|
||||
|
||||
# Service user & data dirs
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin {{.SystemUser}} || true
|
||||
@@ -68,4 +51,3 @@ systemctl daemon-reload
|
||||
systemctl enable {{.ServiceName}}
|
||||
|
||||
echo "=== Setup complete at $(date -u) ==="
|
||||
echo "Edit {{.ConfigPath}} then: systemctl start {{.ServiceName}}"
|
||||
|
||||
@@ -27,6 +27,8 @@ registration:
|
||||
owner_did: "did:plc:pddp4xt5lgnv2qsegbzzs4xg"
|
||||
allow_all_crew: true
|
||||
profile_avatar_url: https://{{.HoldDomain}}/web-app-manifest-192x192.png
|
||||
profile_display_name: Cargo Hold
|
||||
profile_description: ahoy from the cargo hold
|
||||
enable_bluesky_posts: false
|
||||
region: ""
|
||||
database:
|
||||
@@ -35,7 +37,7 @@ database:
|
||||
did_method: web
|
||||
did: ""
|
||||
plc_directory_url: https://plc.directory
|
||||
rotation_key_path: ""
|
||||
rotation_key: ""
|
||||
libsql_sync_url: ""
|
||||
libsql_auth_token: ""
|
||||
libsql_sync_interval: 1m0s
|
||||
|
||||
Binary file not shown.
@@ -1,35 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// requiredGoVersion reads the Go version from the root go.mod file.
|
||||
// Returns a version string like "1.25.7" for use in download URLs.
|
||||
func requiredGoVersion() (string, error) {
|
||||
// projectRoot returns the absolute path to the repository root,
|
||||
// derived from the compile-time source file location.
|
||||
func projectRoot() string {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
rootMod := filepath.Join(filepath.Dir(thisFile), "..", "..", "go.mod")
|
||||
|
||||
data, err := os.ReadFile(rootMod)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read root go.mod: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "go ") {
|
||||
version := strings.TrimPrefix(line, "go ")
|
||||
version = strings.TrimSpace(version)
|
||||
// Validate it looks like a version
|
||||
if len(version) > 0 && version[0] >= '1' && version[0] <= '9' {
|
||||
return version, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no 'go X.Y.Z' directive found in %s", rootMod)
|
||||
return filepath.Join(filepath.Dir(thisFile), "..", "..")
|
||||
}
|
||||
|
||||
+110
-23
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,7 +39,7 @@ func init() {
|
||||
provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)")
|
||||
provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)")
|
||||
provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold")
|
||||
provisionCmd.MarkFlagRequired("ssh-key")
|
||||
_ = provisionCmd.MarkFlagRequired("ssh-key")
|
||||
rootCmd.AddCommand(provisionCmd)
|
||||
}
|
||||
|
||||
@@ -94,16 +95,10 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
state.ScannerSecret = secret
|
||||
fmt.Printf("Generated scanner shared secret\n")
|
||||
}
|
||||
saveState(state)
|
||||
}
|
||||
|
||||
goVersion, err := requiredGoVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
_ = saveState(state)
|
||||
}
|
||||
|
||||
fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone)
|
||||
fmt.Printf("Go version: %s (from go.mod)\n", goVersion)
|
||||
if needsServers {
|
||||
fmt.Printf("Server plan: %s\n", cfg.Plan)
|
||||
}
|
||||
@@ -130,7 +125,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
if discovered.AccessKeyID != "" {
|
||||
state.ObjectStorage.AccessKeyID = discovered.AccessKeyID
|
||||
}
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Creating object storage...")
|
||||
@@ -140,7 +135,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
}
|
||||
state.ObjectStorage = objState
|
||||
s3SecretKey = secretKey
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
fmt.Printf(" S3 Secret Key: %s\n", secretKey)
|
||||
}
|
||||
|
||||
@@ -189,7 +184,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
return fmt.Errorf("create network: %w", err)
|
||||
}
|
||||
state.Network = StateRef{UUID: network.UUID}
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR)
|
||||
}
|
||||
|
||||
@@ -200,9 +195,10 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
}
|
||||
|
||||
// 3. Appview server
|
||||
appviewCreated := false
|
||||
if state.Appview.UUID != "" {
|
||||
fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID)
|
||||
appviewScript, err := generateAppviewCloudInit(cfg, vals, goVersion)
|
||||
appviewScript, err := generateAppviewCloudInit(cfg, vals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -218,7 +214,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Creating appview server...")
|
||||
appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion)
|
||||
appviewUserData, err := generateAppviewCloudInit(cfg, vals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,14 +223,16 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
return fmt.Errorf("create appview: %w", err)
|
||||
}
|
||||
state.Appview = *appview
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
appviewCreated = true
|
||||
fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP)
|
||||
}
|
||||
|
||||
// 4. Hold server
|
||||
holdCreated := false
|
||||
if state.Hold.UUID != "" {
|
||||
fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID)
|
||||
holdScript, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled)
|
||||
holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -259,7 +257,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Creating hold server...")
|
||||
holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled)
|
||||
holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -268,7 +266,8 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
return fmt.Errorf("create hold: %w", err)
|
||||
}
|
||||
state.Hold = *hold
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
holdCreated = true
|
||||
fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP)
|
||||
}
|
||||
|
||||
@@ -296,7 +295,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
return fmt.Errorf("create LB: %w", err)
|
||||
}
|
||||
state.LB = StateRef{UUID: lb.UUID}
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
}
|
||||
|
||||
// Always reconcile forwarded headers rule (handles existing LBs)
|
||||
@@ -325,6 +324,66 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build locally and deploy binaries to new servers
|
||||
if appviewCreated || holdCreated {
|
||||
rootDir := projectRoot()
|
||||
|
||||
fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...")
|
||||
if appviewCreated {
|
||||
outputPath := filepath.Join(rootDir, "bin", "atcr-appview")
|
||||
if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil {
|
||||
return fmt.Errorf("build appview: %w", err)
|
||||
}
|
||||
}
|
||||
if holdCreated {
|
||||
outputPath := filepath.Join(rootDir, "bin", "atcr-hold")
|
||||
if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil {
|
||||
return fmt.Errorf("build hold: %w", err)
|
||||
}
|
||||
if state.ScannerEnabled {
|
||||
outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
|
||||
if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
|
||||
return fmt.Errorf("build scanner: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nWaiting for cloud-init to complete on new servers...")
|
||||
if appviewCreated {
|
||||
if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if holdCreated {
|
||||
if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nDeploying binaries...")
|
||||
if appviewCreated {
|
||||
localPath := filepath.Join(rootDir, "bin", "atcr-appview")
|
||||
remotePath := naming.InstallDir() + "/bin/" + naming.Appview()
|
||||
if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil {
|
||||
return fmt.Errorf("upload appview: %w", err)
|
||||
}
|
||||
}
|
||||
if holdCreated {
|
||||
localPath := filepath.Join(rootDir, "bin", "atcr-hold")
|
||||
remotePath := naming.InstallDir() + "/bin/" + naming.Hold()
|
||||
if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil {
|
||||
return fmt.Errorf("upload hold: %w", err)
|
||||
}
|
||||
if state.ScannerEnabled {
|
||||
scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner")
|
||||
scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner()
|
||||
if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil {
|
||||
return fmt.Errorf("upload scanner: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Provisioning Complete ===")
|
||||
fmt.Println()
|
||||
fmt.Println("DNS records needed:")
|
||||
@@ -343,13 +402,17 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo
|
||||
fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP)
|
||||
fmt.Println()
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Println(" 1. Wait ~5 min for cloud-init to complete")
|
||||
if state.ScannerEnabled {
|
||||
fmt.Printf(" 2. systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner())
|
||||
if appviewCreated || holdCreated {
|
||||
fmt.Println(" 1. Edit configs if needed, then start services:")
|
||||
} else {
|
||||
fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold())
|
||||
fmt.Println(" 1. Start services:")
|
||||
}
|
||||
fmt.Println(" 3. Configure DNS records above")
|
||||
if state.ScannerEnabled {
|
||||
fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner())
|
||||
} else {
|
||||
fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold())
|
||||
}
|
||||
fmt.Println(" 2. Configure DNS records above")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -984,3 +1047,27 @@ func writeRemoteCloudInit(ip, script string) error {
|
||||
_, err := runSSH(ip, cmd, false)
|
||||
return err
|
||||
}
|
||||
|
||||
// waitForSetup polls SSH availability on a newly created server, then waits
|
||||
// for cloud-init to complete before returning.
|
||||
func waitForSetup(ip, name string) error {
|
||||
fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip)
|
||||
for i := 0; i < 30; i++ {
|
||||
_, err := runSSH(ip, "echo ssh_ready", false)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if i == 29 {
|
||||
return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip)
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s: waiting for cloud-init...\n", name)
|
||||
_, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cloud-init wait on %s: %w", name, err)
|
||||
}
|
||||
fmt.Printf(" %s: ready\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
|
||||
// InfraState persists infrastructure resource UUIDs between commands.
|
||||
type InfraState struct {
|
||||
Zone string `json:"zone"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
RepoBranch string `json:"repo_branch,omitempty"`
|
||||
Network StateRef `json:"network"`
|
||||
Appview ServerState `json:"appview"`
|
||||
Hold ServerState `json:"hold"`
|
||||
Zone string `json:"zone"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
RepoBranch string `json:"repo_branch,omitempty"`
|
||||
Network StateRef `json:"network"`
|
||||
Appview ServerState `json:"appview"`
|
||||
Hold ServerState `json:"hold"`
|
||||
LB StateRef `json:"loadbalancer"`
|
||||
ObjectStorage ObjectStorageState `json:"object_storage"`
|
||||
ScannerEnabled bool `json:"scanner_enabled,omitempty"`
|
||||
|
||||
@@ -87,7 +87,7 @@ func cmdTeardown(token string) error {
|
||||
if err != nil {
|
||||
fmt.Printf(" Warning (stop): %v\n", err)
|
||||
} else {
|
||||
svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{
|
||||
_, _ = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{
|
||||
UUID: s.uuid,
|
||||
DesiredState: "stopped",
|
||||
})
|
||||
|
||||
+100
-57
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -50,12 +51,7 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
}
|
||||
|
||||
naming := state.Naming()
|
||||
branch := state.Branch()
|
||||
|
||||
goVersion, err := requiredGoVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rootDir := projectRoot()
|
||||
|
||||
// Enable scanner retroactively via --with-scanner on update
|
||||
if withScanner && !state.ScannerEnabled {
|
||||
@@ -68,7 +64,7 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
state.ScannerSecret = secret
|
||||
fmt.Printf("Generated scanner shared secret\n")
|
||||
}
|
||||
saveState(state)
|
||||
_ = saveState(state)
|
||||
}
|
||||
|
||||
vals := configValsFromState(state)
|
||||
@@ -77,6 +73,7 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
ip string
|
||||
binaryName string
|
||||
buildCmd string
|
||||
localBinary string
|
||||
serviceName string
|
||||
healthURL string
|
||||
configTmpl string
|
||||
@@ -87,6 +84,7 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
ip: state.Appview.PublicIP,
|
||||
binaryName: naming.Appview(),
|
||||
buildCmd: "appview",
|
||||
localBinary: "atcr-appview",
|
||||
serviceName: naming.Appview(),
|
||||
healthURL: "http://localhost:5000/health",
|
||||
configTmpl: appviewConfigTmpl,
|
||||
@@ -97,6 +95,7 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
ip: state.Hold.PublicIP,
|
||||
binaryName: naming.Hold(),
|
||||
buildCmd: "hold",
|
||||
localBinary: "atcr-hold",
|
||||
serviceName: naming.Hold(),
|
||||
healthURL: "http://localhost:8080/xrpc/_health",
|
||||
configTmpl: holdConfigTmpl,
|
||||
@@ -115,9 +114,35 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target)
|
||||
}
|
||||
|
||||
// Build all binaries locally before touching servers
|
||||
fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...")
|
||||
for _, name := range toUpdate {
|
||||
t := targets[name]
|
||||
fmt.Printf("Updating %s (%s)...\n", name, t.ip)
|
||||
outputPath := filepath.Join(rootDir, "bin", t.localBinary)
|
||||
if err := buildLocal(rootDir, outputPath, "./cmd/"+t.buildCmd); err != nil {
|
||||
return fmt.Errorf("build %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build scanner locally if needed
|
||||
needScanner := false
|
||||
for _, name := range toUpdate {
|
||||
if name == "hold" && state.ScannerEnabled {
|
||||
needScanner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needScanner {
|
||||
outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
|
||||
if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
|
||||
return fmt.Errorf("build scanner: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy each target
|
||||
for _, name := range toUpdate {
|
||||
t := targets[name]
|
||||
fmt.Printf("\nDeploying %s (%s)...\n", name, t.ip)
|
||||
|
||||
// Sync config keys (adds missing keys from template, never overwrites)
|
||||
configYAML, err := renderConfig(t.configTmpl, vals)
|
||||
@@ -145,13 +170,19 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
return fmt.Errorf("%s service unit sync: %w", name, err)
|
||||
}
|
||||
|
||||
// Upload binary
|
||||
localPath := filepath.Join(rootDir, "bin", t.localBinary)
|
||||
remotePath := naming.InstallDir() + "/bin/" + t.binaryName
|
||||
if err := scpFile(localPath, t.ip, remotePath); err != nil {
|
||||
return fmt.Errorf("upload %s: %w", name, err)
|
||||
}
|
||||
|
||||
daemonReload := ""
|
||||
if unitChanged {
|
||||
daemonReload = "systemctl daemon-reload"
|
||||
}
|
||||
|
||||
// Scanner additions for hold server
|
||||
scannerBuild := ""
|
||||
scannerRestart := ""
|
||||
scannerHealthCheck := ""
|
||||
if name == "hold" && state.ScannerEnabled {
|
||||
@@ -185,66 +216,42 @@ func cmdUpdate(target string, withScanner bool) error {
|
||||
daemonReload = "systemctl daemon-reload"
|
||||
}
|
||||
|
||||
scannerBuild = fmt.Sprintf(`
|
||||
# Build scanner
|
||||
cd %s/scanner
|
||||
CGO_ENABLED=1 go build \
|
||||
-ldflags="-s -w" \
|
||||
-trimpath \
|
||||
-o ../bin/%s ./cmd/scanner
|
||||
cd %s
|
||||
// Upload scanner binary
|
||||
scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner")
|
||||
scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner()
|
||||
if err := scpFile(scannerLocal, t.ip, scannerRemote); err != nil {
|
||||
return fmt.Errorf("upload scanner: %w", err)
|
||||
}
|
||||
|
||||
# Ensure scanner data dirs exist
|
||||
mkdir -p %s/vulndb %s/tmp
|
||||
chown -R %s:%s %s
|
||||
`, naming.InstallDir(), naming.Scanner(), naming.InstallDir(),
|
||||
// Ensure scanner data dirs exist on server
|
||||
scannerSetup := fmt.Sprintf(`mkdir -p %s/vulndb %s/tmp
|
||||
chown -R %s:%s %s`,
|
||||
naming.ScannerDataDir(), naming.ScannerDataDir(),
|
||||
naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir())
|
||||
if _, err := runSSH(t.ip, scannerSetup, false); err != nil {
|
||||
return fmt.Errorf("scanner dir setup: %w", err)
|
||||
}
|
||||
|
||||
scannerRestart = fmt.Sprintf("\nsystemctl restart %s", naming.Scanner())
|
||||
scannerHealthCheck = fmt.Sprintf(`
|
||||
scannerHealthCheck = `
|
||||
sleep 2
|
||||
curl -sf http://localhost:9090/healthz > /dev/null && echo "SCANNER_HEALTH_OK" || echo "SCANNER_HEALTH_FAIL"
|
||||
`)
|
||||
`
|
||||
}
|
||||
|
||||
updateScript := fmt.Sprintf(`set -euo pipefail
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
export GOTMPDIR=/var/tmp
|
||||
|
||||
# Update Go if needed
|
||||
CURRENT_GO=$(go version 2>/dev/null | grep -oP 'go\K[0-9.]+' || echo "none")
|
||||
REQUIRED_GO="%s"
|
||||
if [ "$CURRENT_GO" != "$REQUIRED_GO" ]; then
|
||||
echo "Updating Go: $CURRENT_GO -> $REQUIRED_GO"
|
||||
rm -rf /usr/local/go
|
||||
curl -fsSL https://go.dev/dl/go${REQUIRED_GO}.linux-amd64.tar.gz | tar -C /usr/local -xz
|
||||
fi
|
||||
|
||||
cd %s
|
||||
git pull origin %s
|
||||
npm ci
|
||||
go generate ./...
|
||||
CGO_ENABLED=1 go build \
|
||||
-ldflags="-s -w -linkmode external -extldflags '-static'" \
|
||||
-tags sqlite_omit_load_extension -trimpath \
|
||||
-o bin/%s ./cmd/%s
|
||||
%s
|
||||
%s
|
||||
systemctl restart %s
|
||||
// Restart services and health check
|
||||
restartScript := fmt.Sprintf(`set -euo pipefail
|
||||
%s
|
||||
systemctl restart %s%s
|
||||
sleep 2
|
||||
curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL"
|
||||
%s
|
||||
`, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd,
|
||||
scannerBuild, daemonReload, t.serviceName, scannerRestart,
|
||||
t.healthURL, scannerHealthCheck)
|
||||
%s`, daemonReload, t.serviceName, scannerRestart, t.healthURL, scannerHealthCheck)
|
||||
|
||||
output, err := runSSH(t.ip, updateScript, true)
|
||||
output, err := runSSH(t.ip, restartScript, true)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: %v\n", err)
|
||||
fmt.Printf(" Output: %s\n", output)
|
||||
return fmt.Errorf("update %s failed", name)
|
||||
return fmt.Errorf("restart %s failed", name)
|
||||
}
|
||||
|
||||
if strings.Contains(output, "HEALTH_OK") {
|
||||
@@ -292,6 +299,42 @@ func configValsFromState(state *InfraState) *ConfigValues {
|
||||
}
|
||||
}
|
||||
|
||||
// buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64.
|
||||
func buildLocal(dir, outputPath, buildPkg string) error {
|
||||
fmt.Printf(" building %s...\n", filepath.Base(outputPath))
|
||||
cmd := exec.Command("go", "build",
|
||||
"-ldflags=-s -w",
|
||||
"-trimpath",
|
||||
"-o", outputPath,
|
||||
buildPkg,
|
||||
)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=linux",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=1",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// scpFile uploads a local file to a remote server via SCP.
|
||||
// Removes the remote file first to avoid ETXTBSY when overwriting a running binary.
|
||||
func scpFile(localPath, ip, remotePath string) error {
|
||||
fmt.Printf(" uploading %s → %s:%s\n", filepath.Base(localPath), ip, remotePath)
|
||||
_, _ = runSSH(ip, fmt.Sprintf("rm -f %s", remotePath), false)
|
||||
cmd := exec.Command("scp",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=10",
|
||||
localPath,
|
||||
"root@"+ip+":"+remotePath,
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cmdSSH(target string) error {
|
||||
state, err := loadState()
|
||||
if err != nil {
|
||||
@@ -337,15 +380,15 @@ func runSSH(ip, script string, stream bool) (string, error) {
|
||||
cmd.Stderr = &buf
|
||||
}
|
||||
|
||||
// Give builds up to 10 minutes
|
||||
// Give deploys up to 5 minutes (SCP + restart, much faster than remote builds)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Run() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return buf.String(), err
|
||||
case <-time.After(10 * time.Minute):
|
||||
cmd.Process.Kill()
|
||||
return buf.String(), fmt.Errorf("SSH command timed out after 10 minutes")
|
||||
case <-time.After(5 * time.Minute):
|
||||
_ = cmd.Process.Kill()
|
||||
return buf.String(), fmt.Errorf("SSH command timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestMain(m *testing.M) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "", "")
|
||||
err = sharedPublicPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", Public: true})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestMain(m *testing.M) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "", "")
|
||||
err = sharedPrivatePDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestMain(m *testing.M) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "", "")
|
||||
err = sharedAllowCrewPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", AllowAllCrew: true})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func createTestHoldPDS(t *testing.T, ownerDID string, public bool, allowAllCrew
|
||||
|
||||
// Bootstrap with owner if provided
|
||||
if ownerDID != "" {
|
||||
err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
|
||||
err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bootstrap HoldPDS: %v", err)
|
||||
}
|
||||
|
||||
+11
-6
@@ -56,6 +56,12 @@ type RegistrationConfig struct {
|
||||
// URL to fetch avatar image from during bootstrap.
|
||||
ProfileAvatarURL string `yaml:"profile_avatar_url" comment:"URL to fetch avatar image from during bootstrap."`
|
||||
|
||||
// Bluesky profile display name. Synced on every startup.
|
||||
ProfileDisplayName string `yaml:"profile_display_name" comment:"Bluesky profile display name. Synced on every startup."`
|
||||
|
||||
// Bluesky profile description. Synced on every startup.
|
||||
ProfileDescription string `yaml:"profile_description" comment:"Bluesky profile description. Synced on every startup."`
|
||||
|
||||
// Post to Bluesky when users push images.
|
||||
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts" comment:"Post to Bluesky when users push images. Synced to captain record on startup."`
|
||||
|
||||
@@ -152,8 +158,8 @@ type DatabaseConfig struct {
|
||||
// PLC directory URL. Only used when did_method is "plc".
|
||||
PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory"`
|
||||
|
||||
// Rotation key path for did:plc. Separate from signing key for recovery.
|
||||
RotationKeyPath string `yaml:"rotation_key_path" comment:"Rotation key path for did:plc. Controls DID identity (separate from signing key). Defaults to {database.path}/rotation.key."`
|
||||
// Rotation key for did:plc (multibase-encoded private key, K-256 or P-256).
|
||||
RotationKey string `yaml:"rotation_key" comment:"Rotation key for did:plc in multibase format (starting with 'z'). Generate with: goat key generate. Supports K-256 and P-256 curves. Controls DID identity (separate from signing key)."`
|
||||
|
||||
// libSQL sync URL for embedded replica mode.
|
||||
LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite."`
|
||||
@@ -184,6 +190,8 @@ func setHoldDefaults(v *viper.Viper) {
|
||||
v.SetDefault("registration.owner_did", "")
|
||||
v.SetDefault("registration.allow_all_crew", false)
|
||||
v.SetDefault("registration.profile_avatar_url", "https://atcr.io/web-app-manifest-192x192.png")
|
||||
v.SetDefault("registration.profile_display_name", "Cargo Hold")
|
||||
v.SetDefault("registration.profile_description", "ahoy from the cargo hold")
|
||||
v.SetDefault("registration.enable_bluesky_posts", false)
|
||||
|
||||
// Database defaults
|
||||
@@ -192,7 +200,7 @@ func setHoldDefaults(v *viper.Viper) {
|
||||
v.SetDefault("database.did_method", "web")
|
||||
v.SetDefault("database.did", "")
|
||||
v.SetDefault("database.plc_directory_url", "https://plc.directory")
|
||||
v.SetDefault("database.rotation_key_path", "")
|
||||
v.SetDefault("database.rotation_key", "")
|
||||
v.SetDefault("database.libsql_sync_url", "")
|
||||
v.SetDefault("database.libsql_auth_token", "")
|
||||
v.SetDefault("database.libsql_sync_interval", "60s")
|
||||
@@ -287,9 +295,6 @@ func LoadConfig(yamlPath string) (*Config, error) {
|
||||
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
|
||||
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
|
||||
}
|
||||
if cfg.Database.RotationKeyPath == "" && cfg.Database.Path != "" {
|
||||
cfg.Database.RotationKeyPath = filepath.Join(cfg.Database.Path, "rotation.key")
|
||||
}
|
||||
|
||||
// Validate DID method
|
||||
if cfg.Database.DIDMethod != "" && cfg.Database.DIDMethod != "web" && cfg.Database.DIDMethod != "plc" {
|
||||
|
||||
@@ -106,7 +106,7 @@ func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -191,7 +191,7 @@ func setupTestOCIHandlerWithS3(t *testing.T) (*XRPCHandler, bool) {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
|
||||
@@ -56,7 +56,7 @@ func setupTestPDSWithBootstrap(t *testing.T, ownerDID string, public, allowAllCr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
|
||||
err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew})
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
+30
-20
@@ -118,7 +118,7 @@ type DIDConfig struct {
|
||||
PublicURL string
|
||||
DBPath string
|
||||
SigningKeyPath string
|
||||
RotationKeyPath string
|
||||
RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional)
|
||||
PLCDirectoryURL string
|
||||
}
|
||||
|
||||
@@ -166,8 +166,8 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) {
|
||||
return "", fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Try to load rotation key (optional — may be stored offline)
|
||||
rotationKey, _ := loadOptionalK256Key(cfg.RotationKeyPath)
|
||||
// Try to parse rotation key (optional — may not be configured)
|
||||
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
|
||||
if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil {
|
||||
return "", fmt.Errorf("failed to ensure PLC identity is current: %w", err)
|
||||
@@ -185,10 +185,23 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) {
|
||||
return "", fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Load or generate rotation key
|
||||
rotationKey, err := oauth.GenerateOrLoadPDSKey(cfg.RotationKeyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load rotation key: %w", err)
|
||||
// Parse or generate rotation key
|
||||
var rotationKey atcrypto.PrivateKeyExportable
|
||||
if cfg.RotationKey != "" {
|
||||
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse rotation_key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Generate a new rotation key — user must save the multibase output
|
||||
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
|
||||
if genErr != nil {
|
||||
return "", fmt.Errorf("failed to generate rotation key: %w", genErr)
|
||||
}
|
||||
rotationKey = rawKey
|
||||
slog.Warn("Generated new rotation key — save this in your config as database.rotation_key",
|
||||
"rotation_key", rawKey.Multibase(),
|
||||
)
|
||||
}
|
||||
|
||||
did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL)
|
||||
@@ -208,23 +221,20 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) {
|
||||
"did", did,
|
||||
"plc_directory", cfg.PLCDirectoryURL,
|
||||
)
|
||||
slog.Warn("Back up rotation.key and optionally remove it from the server. It is only needed for DID updates (URL changes, key rotation).",
|
||||
"rotation_key_path", cfg.RotationKeyPath,
|
||||
)
|
||||
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
|
||||
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// loadOptionalK256Key attempts to load a K-256 private key from disk.
|
||||
// Returns nil if the file does not exist (key stored offline).
|
||||
func loadOptionalK256Key(path string) (*atcrypto.PrivateKeyK256, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256).
|
||||
// Returns nil, nil if the input is empty (key not configured).
|
||||
func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) {
|
||||
if encoded == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := atcrypto.ParsePrivateBytesK256(data)
|
||||
key, err := atcrypto.ParsePrivateMultibase(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse K-256 key from %s: %w", path, err)
|
||||
return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -232,7 +242,7 @@ func loadOptionalK256Key(path string) (*atcrypto.PrivateKeyK256, error) {
|
||||
// EnsurePLCCurrent checks the PLC directory for the given DID and updates it
|
||||
// if the local signing key or public URL doesn't match what's registered.
|
||||
// If rotationKey is nil, mismatches are logged as warnings but not fatal.
|
||||
func EnsurePLCCurrent(ctx context.Context, did string, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error {
|
||||
func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error {
|
||||
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
|
||||
|
||||
// Fetch current op log
|
||||
@@ -340,7 +350,7 @@ func EnsurePLCCurrent(ctx context.Context, did string, rotationKey, signingKey *
|
||||
|
||||
// CreatePLCIdentity creates a new did:plc identity by building a genesis operation,
|
||||
// signing it with the rotation key, and submitting it to the PLC directory.
|
||||
func CreatePLCIdentity(ctx context.Context, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) {
|
||||
func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) {
|
||||
rotPub, err := rotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get rotation public key: %w", err)
|
||||
|
||||
@@ -308,7 +308,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) *HoldPDS {
|
||||
}
|
||||
|
||||
// Bootstrap with owner
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil {
|
||||
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,16 @@ func (p *HoldPDS) CreateProfileRecord(ctx context.Context, s3svc *s3.S3Service,
|
||||
return recordCID, nil
|
||||
}
|
||||
|
||||
// UpdateProfileRecord updates the existing app.bsky.actor.profile record.
|
||||
// Callers should GetProfileRecord first, modify fields, then pass the updated record.
|
||||
func (p *HoldPDS) UpdateProfileRecord(ctx context.Context, record *bsky.ActorProfile) (cid.Cid, error) {
|
||||
recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, ProfileCollection, ProfileRkey, record)
|
||||
if err != nil {
|
||||
return cid.Undef, fmt.Errorf("failed to update profile record: %w", err)
|
||||
}
|
||||
return recordCID, nil
|
||||
}
|
||||
|
||||
// GetProfileRecord retrieves the app.bsky.actor.profile record
|
||||
func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) {
|
||||
// Use repomgr.GetRecord
|
||||
|
||||
+72
-28
@@ -230,9 +230,21 @@ func (p *HoldPDS) GetRecordBytes(ctx context.Context, recordPath string) (cid.Ci
|
||||
return recordCID, recBytes, nil
|
||||
}
|
||||
|
||||
// BootstrapConfig holds all configuration needed for Bootstrap.
|
||||
// Defined in the pds package to avoid circular imports with the hold package.
|
||||
type BootstrapConfig struct {
|
||||
OwnerDID string // DID of the hold captain
|
||||
Public bool // Allow unauthenticated blob reads
|
||||
AllowAllCrew bool // Create wildcard crew record
|
||||
ProfileAvatarURL string // URL to fetch avatar image from
|
||||
ProfileDisplayName string // Bluesky profile display name
|
||||
ProfileDescription string // Bluesky profile description
|
||||
Region string // Deployment region
|
||||
}
|
||||
|
||||
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
|
||||
if ownerDID == "" {
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, cfg BootstrapConfig) error {
|
||||
if cfg.OwnerDID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -244,7 +256,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s
|
||||
// Captain record exists, skip captain/crew setup but still create profile if needed
|
||||
slog.Info("Captain record exists, skipping captain/crew setup")
|
||||
} else {
|
||||
slog.Info("Bootstrapping hold PDS", "owner", ownerDID)
|
||||
slog.Info("Bootstrapping hold PDS", "owner", cfg.OwnerDID)
|
||||
}
|
||||
|
||||
if !captainExists {
|
||||
@@ -263,45 +275,45 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s
|
||||
}
|
||||
|
||||
// Create captain record (hold ownership and settings)
|
||||
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region)
|
||||
_, err = p.CreateCaptainRecord(ctx, cfg.OwnerDID, cfg.Public, cfg.AllowAllCrew, p.enableBlueskyPosts, cfg.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create captain record: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Created captain record",
|
||||
"public", public,
|
||||
"allowAllCrew", allowAllCrew,
|
||||
"public", cfg.Public,
|
||||
"allowAllCrew", cfg.AllowAllCrew,
|
||||
"enableBlueskyPosts", p.enableBlueskyPosts,
|
||||
"region", region)
|
||||
"region", cfg.Region)
|
||||
|
||||
// Add hold owner as first crew member with admin role
|
||||
_, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
|
||||
_, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add owner as crew member: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Added owner as hold admin", "did", ownerDID)
|
||||
slog.Info("Added owner as hold admin", "did", cfg.OwnerDID)
|
||||
} else {
|
||||
// Captain record exists, check if we need to sync settings from env vars
|
||||
// Captain record exists, check if we need to sync settings from config
|
||||
_, existingCaptain, err := p.GetCaptainRecord(ctx)
|
||||
if err == nil {
|
||||
// Check if any settings need updating
|
||||
needsUpdate := existingCaptain.Public != public ||
|
||||
existingCaptain.AllowAllCrew != allowAllCrew ||
|
||||
needsUpdate := existingCaptain.Public != cfg.Public ||
|
||||
existingCaptain.AllowAllCrew != cfg.AllowAllCrew ||
|
||||
existingCaptain.EnableBlueskyPosts != p.enableBlueskyPosts
|
||||
|
||||
if needsUpdate {
|
||||
// Update captain record to match env vars (preserves other fields like Successor)
|
||||
existingCaptain.Public = public
|
||||
existingCaptain.AllowAllCrew = allowAllCrew
|
||||
// Update captain record to match config (preserves other fields like Successor)
|
||||
existingCaptain.Public = cfg.Public
|
||||
existingCaptain.AllowAllCrew = cfg.AllowAllCrew
|
||||
existingCaptain.EnableBlueskyPosts = p.enableBlueskyPosts
|
||||
_, err = p.UpdateCaptainRecord(ctx, existingCaptain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update captain record: %w", err)
|
||||
}
|
||||
slog.Info("Synced captain record with env vars",
|
||||
"public", public,
|
||||
"allowAllCrew", allowAllCrew,
|
||||
slog.Info("Synced captain record from config",
|
||||
"public", cfg.Public,
|
||||
"allowAllCrew", cfg.AllowAllCrew,
|
||||
"enableBlueskyPosts", p.enableBlueskyPosts)
|
||||
}
|
||||
}
|
||||
@@ -315,23 +327,55 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s
|
||||
slog.Info("Migrated crew records to hash-based rkeys", "count", migrated)
|
||||
}
|
||||
|
||||
// Create Bluesky profile record (idempotent - check if exists first)
|
||||
// Create or sync Bluesky profile record from config
|
||||
// This runs even if captain exists (for existing holds being upgraded)
|
||||
// Skip if no S3 service (e.g., in tests)
|
||||
if s3svc != nil {
|
||||
_, _, err = p.GetProfileRecord(ctx)
|
||||
if err != nil {
|
||||
// Bluesky profile doesn't exist, create it
|
||||
displayName := "Cargo Hold"
|
||||
description := "ahoy from the cargo hold"
|
||||
|
||||
_, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL)
|
||||
_, existingProfile, profileErr := p.GetProfileRecord(ctx)
|
||||
if profileErr != nil {
|
||||
// Profile doesn't exist, create it fresh
|
||||
_, err = p.CreateProfileRecord(ctx, s3svc, cfg.ProfileDisplayName, cfg.ProfileDescription, cfg.ProfileAvatarURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bluesky profile record: %w", err)
|
||||
}
|
||||
slog.Info("Created Bluesky profile record", "displayName", displayName)
|
||||
slog.Info("Created Bluesky profile record", "displayName", cfg.ProfileDisplayName)
|
||||
} else {
|
||||
slog.Info("Bluesky profile record already exists, skipping")
|
||||
// Profile exists — sync fields from config (like captain record sync above)
|
||||
needsUpdate := false
|
||||
|
||||
if cfg.ProfileDisplayName != "" && (existingProfile.DisplayName == nil || *existingProfile.DisplayName != cfg.ProfileDisplayName) {
|
||||
existingProfile.DisplayName = &cfg.ProfileDisplayName
|
||||
needsUpdate = true
|
||||
}
|
||||
if cfg.ProfileDescription != "" && (existingProfile.Description == nil || *existingProfile.Description != cfg.ProfileDescription) {
|
||||
existingProfile.Description = &cfg.ProfileDescription
|
||||
needsUpdate = true
|
||||
}
|
||||
if cfg.ProfileAvatarURL != "" && existingProfile.Avatar == nil {
|
||||
imageData, mimeType, dlErr := downloadImage(ctx, cfg.ProfileAvatarURL)
|
||||
if dlErr != nil {
|
||||
slog.Warn("Failed to download avatar for profile update", "error", dlErr)
|
||||
} else {
|
||||
avatarBlob, uploadErr := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType)
|
||||
if uploadErr != nil {
|
||||
slog.Warn("Failed to upload avatar for profile update", "error", uploadErr)
|
||||
} else {
|
||||
existingProfile.Avatar = avatarBlob
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
_, err = p.UpdateProfileRecord(ctx, existingProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update bluesky profile record: %w", err)
|
||||
}
|
||||
slog.Info("Synced Bluesky profile record from config",
|
||||
"displayName", cfg.ProfileDisplayName)
|
||||
} else {
|
||||
slog.Info("Bluesky profile record already matches config, skipping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
|
||||
|
||||
// Bootstrap with a captain record
|
||||
ownerDID := "did:plc:owner123"
|
||||
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
if err := pds1.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) {
|
||||
publicAccess := true
|
||||
allowAllCrew := false
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
|
||||
ownerDID := "did:plc:alice123"
|
||||
|
||||
// First bootstrap
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("First bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
|
||||
crewCount1 := len(crew1)
|
||||
|
||||
// Second bootstrap (should be idempotent - skip creation)
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Second bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) {
|
||||
defer pds.Close()
|
||||
|
||||
// Bootstrap with empty owner DID (should be no-op)
|
||||
err = pds.Bootstrap(ctx, nil, "", true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap with empty owner should not error: %v", err)
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) {
|
||||
|
||||
// Bootstrap to create captain record
|
||||
ownerDID := "did:plc:alice123"
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) {
|
||||
publicAccess := true
|
||||
allowAllCrew := false
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed with did:web owner: %v", err)
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
|
||||
|
||||
// Bootstrap with did:plc owner
|
||||
plcOwner := "did:plc:alice123"
|
||||
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: plcOwner, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
|
||||
}
|
||||
|
||||
// Bootstrap should create captain record
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -584,7 +584,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
|
||||
|
||||
// Bootstrap should be idempotent but notice missing crew
|
||||
// Currently Bootstrap skips if captain exists, so crew won't be added
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -856,7 +856,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
|
||||
|
||||
// Bootstrap to create some records in MST (captain + crew)
|
||||
ownerDID := "did:plc:testowner"
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -921,7 +921,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
|
||||
defer pds.Close()
|
||||
|
||||
// Bootstrap to create records
|
||||
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner", Public: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
// Bootstrap once
|
||||
ownerDID := "did:plc:testowner123"
|
||||
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "")
|
||||
err = sharedPDS.Bootstrap(sharedCtx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err))
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -114,7 +114,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -1987,7 +1987,7 @@ func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Clien
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -2044,7 +2044,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true})
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -2619,7 +2619,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) {
|
||||
|
||||
// Clean up - recreate captain record if it was deleted
|
||||
if w.Code == http.StatusOK {
|
||||
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "")
|
||||
handler.pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner123", Public: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -79,7 +79,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKeyPath: cfg.Database.RotationKeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -124,7 +124,15 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
}
|
||||
|
||||
// Bootstrap PDS with captain record, hold owner as first crew member, and profile
|
||||
if err := s.PDS.Bootstrap(ctx, s3Service, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil {
|
||||
if err := s.PDS.Bootstrap(ctx, s3Service, pds.BootstrapConfig{
|
||||
OwnerDID: cfg.Registration.OwnerDID,
|
||||
Public: cfg.Server.Public,
|
||||
AllowAllCrew: cfg.Registration.AllowAllCrew,
|
||||
ProfileAvatarURL: cfg.Registration.ProfileAvatarURL,
|
||||
ProfileDisplayName: cfg.Registration.ProfileDisplayName,
|
||||
ProfileDescription: cfg.Registration.ProfileDescription,
|
||||
Region: cfg.Registration.Region,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to bootstrap PDS: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user