tweaks related to did:plc, fix bluesky profile creation, update deploys to build locally then scp

This commit is contained in:
Evan Jarrett
2026-02-14 21:00:07 -06:00
parent e3843db9d8
commit f340158a79
26 changed files with 568 additions and 251 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -77,6 +77,7 @@ func init() {
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(plcCmd)
}
func main() {
+164
View File
@@ -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
View File
@@ -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 {
+6 -2
View File
@@ -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.
+9 -30
View File
@@ -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
+3 -21
View File
@@ -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}}"
+3 -1
View File
@@ -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.
+4 -26
View File
@@ -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
View File
@@ -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
}
+6 -6
View File
@@ -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"`
+1 -1
View File
@@ -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
View File
@@ -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")
}
}
+4 -4
View File
@@ -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
View File
@@ -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" {
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+1 -1
View File
@@ -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))
}
+5 -5
View File
@@ -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
View File
@@ -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)
}