Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle: move secrets to repoDID -keyed

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (May 12, 2026, 3:07 PM +0300) commit 676307b0 parent f1d74112 change-id tqzwrmxt
+415 -69
+1 -12
spindle/models/clone.go
··· 103 103 104 104 // BuildRepoURL constructs the repository URL from repo metadata. 105 105 func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo, devMode bool) string { 106 - if repo == nil { 107 - return "" 108 - } 109 - 110 106 scheme := "https://" 111 107 if devMode { 112 108 scheme = "http://" ··· 120 116 host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 121 117 } 122 118 123 - switch { 124 - case repo.RepoDid != nil: 125 - return fmt.Sprintf("%s%s/%s", scheme, host, *repo.RepoDid) 126 - case repo.Repo != nil: 127 - return fmt.Sprintf("%s%s/%s/%s", scheme, host, repo.Did, *repo.Repo) 128 - default: 129 - return "" 130 - } 119 + return fmt.Sprintf("%s%s/%s", scheme, host, *repo.RepoDid) 131 120 } 132 121 133 122 // buildFetchArgs constructs the arguments for git fetch based on clone options
+46 -35
spindle/models/clone_test.go
··· 26 26 Ref: "refs/heads/main", 27 27 }, 28 28 Repo: &tangled.Pipeline_TriggerRepo{ 29 - Knot: "example.com", 30 - Did: "did:plc:user123", 31 - Repo: sp("my-repo"), 29 + Knot: "example.com", 30 + Did: "did:plc:user123", 31 + Repo: sp("my-repo"), 32 + RepoDid: sp("did:plc:boltless"), 32 33 }, 33 34 } 34 35 ··· 64 65 if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 65 66 t.Error("Commands should contain 'git checkout FETCH_HEAD'") 66 67 } 67 - if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") { 68 + if !strings.Contains(allCmds, "https://example.com/did:plc:boltless") { 68 69 t.Error("Commands should contain expected repo URL") 69 70 } 70 71 } ··· 85 86 Action: "opened", 86 87 }, 87 88 Repo: &tangled.Pipeline_TriggerRepo{ 88 - Knot: "example.com", 89 - Did: "did:plc:user123", 90 - Repo: sp("my-repo"), 89 + Knot: "example.com", 90 + Did: "did:plc:user123", 91 + Repo: sp("my-repo"), 92 + RepoDid: sp("did:plc:boltless"), 91 93 }, 92 94 } 93 95 ··· 112 114 Inputs: nil, 113 115 }, 114 116 Repo: &tangled.Pipeline_TriggerRepo{ 115 - Knot: "example.com", 116 - Did: "did:plc:user123", 117 - Repo: sp("my-repo"), 117 + Knot: "example.com", 118 + Did: "did:plc:user123", 119 + Repo: sp("my-repo"), 120 + RepoDid: sp("did:plc:boltless"), 118 121 }, 119 122 } 120 123 ··· 143 146 NewSha: "abc123", 144 147 }, 145 148 Repo: &tangled.Pipeline_TriggerRepo{ 146 - Knot: "example.com", 147 - Did: "did:plc:user123", 148 - Repo: sp("my-repo"), 149 + Knot: "example.com", 150 + Did: "did:plc:user123", 151 + Repo: sp("my-repo"), 152 + RepoDid: sp("did:plc:boltless"), 149 153 }, 150 154 } 151 155 ··· 173 177 NewSha: "abc123", 174 178 }, 175 179 Repo: &tangled.Pipeline_TriggerRepo{ 176 - Knot: "localhost:3000", 177 - Did: "did:plc:user123", 178 - Repo: sp("my-repo"), 180 + Knot: "localhost:3000", 181 + Did: "did:plc:user123", 182 + Repo: sp("my-repo"), 183 + RepoDid: sp("did:plc:boltless"), 179 184 }, 180 185 } 181 186 ··· 183 188 184 189 // In dev mode, should use http:// and replace localhost with host.docker.internal 185 190 allCmds := strings.Join(step.Commands(), " ") 186 - expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 191 + expectedURL := "http://host.docker.internal:3000/did:plc:boltless" 187 192 if !strings.Contains(allCmds, expectedURL) { 188 193 t.Errorf("Expected dev mode URL '%s' in commands", expectedURL) 189 194 } ··· 203 208 NewSha: "abc123", 204 209 }, 205 210 Repo: &tangled.Pipeline_TriggerRepo{ 206 - Knot: "example.com", 207 - Did: "did:plc:user123", 208 - Repo: sp("my-repo"), 211 + Knot: "example.com", 212 + Did: "did:plc:user123", 213 + Repo: sp("my-repo"), 214 + RepoDid: sp("did:plc:boltless"), 209 215 }, 210 216 } 211 217 ··· 234 240 NewSha: "abc123", 235 241 }, 236 242 Repo: &tangled.Pipeline_TriggerRepo{ 237 - Knot: "example.com", 238 - Did: "did:plc:user123", 239 - Repo: sp("my-repo"), 243 + Knot: "example.com", 244 + Did: "did:plc:user123", 245 + Repo: sp("my-repo"), 246 + RepoDid: sp("did:plc:boltless"), 240 247 }, 241 248 } 242 249 ··· 259 266 Kind: string(workflow.TriggerKindPush), 260 267 Push: nil, // Nil push data should create error step 261 268 Repo: &tangled.Pipeline_TriggerRepo{ 262 - Knot: "example.com", 263 - Did: "did:plc:user123", 264 - Repo: sp("my-repo"), 269 + Knot: "example.com", 270 + Did: "did:plc:user123", 271 + Repo: sp("my-repo"), 272 + RepoDid: sp("did:plc:boltless"), 265 273 }, 266 274 } 267 275 ··· 292 300 Kind: string(workflow.TriggerKindPullRequest), 293 301 PullRequest: nil, // Nil PR data should create error step 294 302 Repo: &tangled.Pipeline_TriggerRepo{ 295 - Knot: "example.com", 296 - Did: "did:plc:user123", 297 - Repo: sp("my-repo"), 303 + Knot: "example.com", 304 + Did: "did:plc:user123", 305 + Repo: sp("my-repo"), 306 + RepoDid: sp("did:plc:boltless"), 298 307 }, 299 308 } 300 309 ··· 321 330 tr := tangled.Pipeline_TriggerMetadata{ 322 331 Kind: "unknown_trigger", 323 332 Repo: &tangled.Pipeline_TriggerRepo{ 324 - Knot: "example.com", 325 - Did: "did:plc:user123", 326 - Repo: sp("my-repo"), 333 + Knot: "example.com", 334 + Did: "did:plc:user123", 335 + Repo: sp("my-repo"), 336 + RepoDid: sp("did:plc:boltless"), 327 337 }, 328 338 } 329 339 ··· 350 360 NewSha: "abc123", 351 361 }, 352 362 Repo: &tangled.Pipeline_TriggerRepo{ 353 - Knot: "example.com", 354 - Did: "did:plc:user123", 355 - Repo: sp("my-repo"), 363 + Knot: "example.com", 364 + Did: "did:plc:user123", 365 + Repo: sp("my-repo"), 366 + RepoDid: sp("did:plc:boltless"), 356 367 }, 357 368 } 358 369
+24 -18
spindle/models/pipeline_env_test.go
··· 19 19 Knot: "example.com", 20 20 Did: "did:plc:user123", 21 21 Repo: sp("my-repo"), 22 + RepoDid: sp("did:plc:boltless"), 22 23 DefaultBranch: "main", 23 24 }, 24 25 } ··· 65 66 if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" { 66 67 t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"]) 67 68 } 68 - if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" { 69 - t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"]) 69 + if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:boltless" { 70 + t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:boltless', got '%s'", env["TANGLED_REPO_URL"]) 70 71 } 71 72 } 72 73 ··· 79 80 Ref: "refs/tags/v1.2.3", 80 81 }, 81 82 Repo: &tangled.Pipeline_TriggerRepo{ 82 - Knot: "example.com", 83 - Did: "did:plc:user123", 84 - Repo: sp("my-repo"), 83 + Knot: "example.com", 84 + Did: "did:plc:user123", 85 + Repo: sp("my-repo"), 86 + RepoDid: sp("did:plc:boltless"), 85 87 }, 86 88 } 87 89 id := PipelineId{ ··· 111 113 Action: "opened", 112 114 }, 113 115 Repo: &tangled.Pipeline_TriggerRepo{ 114 - Knot: "example.com", 115 - Did: "did:plc:user123", 116 - Repo: sp("my-repo"), 116 + Knot: "example.com", 117 + Did: "did:plc:user123", 118 + Repo: sp("my-repo"), 119 + RepoDid: sp("did:plc:boltless"), 117 120 }, 118 121 } 119 122 id := PipelineId{ ··· 166 169 }, 167 170 }, 168 171 Repo: &tangled.Pipeline_TriggerRepo{ 169 - Knot: "example.com", 170 - Did: "did:plc:user123", 171 - Repo: sp("my-repo"), 172 + Knot: "example.com", 173 + Did: "did:plc:user123", 174 + Repo: sp("my-repo"), 175 + RepoDid: sp("did:plc:boltless"), 172 176 }, 173 177 } 174 178 id := PipelineId{ ··· 202 206 Ref: "refs/heads/main", 203 207 }, 204 208 Repo: &tangled.Pipeline_TriggerRepo{ 205 - Knot: "localhost:3000", 206 - Did: "did:plc:user123", 207 - Repo: sp("my-repo"), 209 + Knot: "localhost:3000", 210 + Did: "did:plc:user123", 211 + Repo: sp("my-repo"), 212 + RepoDid: sp("did:plc:boltless"), 208 213 }, 209 214 } 210 215 id := PipelineId{ ··· 214 219 env := PipelineEnvVars(tr, id, true) 215 220 216 221 // Dev mode should use http:// and replace localhost with host.docker.internal 217 - expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 222 + expectedURL := "http://host.docker.internal:3000/did:plc:boltless" 218 223 if env["TANGLED_REPO_URL"] != expectedURL { 219 224 t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"]) 220 225 } ··· 237 242 Kind: string(workflow.TriggerKindPush), 238 243 Push: nil, 239 244 Repo: &tangled.Pipeline_TriggerRepo{ 240 - Knot: "example.com", 241 - Did: "did:plc:user123", 242 - Repo: sp("my-repo"), 245 + Knot: "example.com", 246 + Did: "did:plc:user123", 247 + Repo: sp("my-repo"), 248 + RepoDid: sp("did:plc:boltless"), 243 249 }, 244 250 } 245 251 id := PipelineId{
+39
spindle/secret_copy.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + "tangled.org/core/spindle/secrets" 9 + ) 10 + 11 + func copyRepoSecrets(ctx context.Context, mgr secrets.Manager, src, dst secrets.RepoIdentifier) (int, error) { 12 + cur, err := mgr.GetSecretsUnlocked(ctx, src) 13 + if err != nil { 14 + return 0, fmt.Errorf("get %s: %w", src, err) 15 + } 16 + var step func(remaining []secrets.UnlockedSecret, copied int) (int, error) 17 + step = func(remaining []secrets.UnlockedSecret, copied int) (int, error) { 18 + if len(remaining) == 0 { 19 + return copied, nil 20 + } 21 + s := remaining[0] 22 + addErr := mgr.AddSecret(ctx, secrets.UnlockedSecret{ 23 + Repo: dst, 24 + Key: s.Key, 25 + Value: s.Value, 26 + CreatedAt: s.CreatedAt, 27 + CreatedBy: s.CreatedBy, 28 + }) 29 + switch { 30 + case addErr == nil: 31 + return step(remaining[1:], copied+1) 32 + case errors.Is(addErr, secrets.ErrKeyAlreadyPresent): 33 + return step(remaining[1:], copied) 34 + default: 35 + return copied, fmt.Errorf("add %s/%s: %w", dst, s.Key, addErr) 36 + } 37 + } 38 + return step(cur, 0) 39 + }
+5 -1
spindle/secrets/openbao.go
··· 98 98 return ErrKeyAlreadyPresent 99 99 } 100 100 101 + createdAt := secret.CreatedAt 102 + if createdAt.IsZero() { 103 + createdAt = time.Now() 104 + } 101 105 secretData := map[string]interface{}{ 102 106 "value": secret.Value, 103 107 "repo": string(secret.Repo), 104 108 "key": secret.Key, 105 - "created_at": secret.CreatedAt.Format(time.RFC3339), 109 + "created_at": createdAt.UTC().Format(time.RFC3339), 106 110 "created_by": secret.CreatedBy.String(), 107 111 } 108 112
+7 -3
spindle/secrets/sqlite.go
··· 64 64 65 65 func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 66 66 query := fmt.Sprintf(` 67 - insert or ignore into %s (repo, key, value, created_by) 68 - values (?, ?, ?, ?); 67 + insert or ignore into %s (repo, key, value, created_at, created_by) 68 + values (?, ?, ?, ?, ?); 69 69 `, s.tableName) 70 70 71 - res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 71 + createdAt := secret.CreatedAt 72 + if createdAt.IsZero() { 73 + createdAt = time.Now() 74 + } 75 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, createdAt.UTC().Format(time.RFC3339), secret.CreatedBy) 72 76 if err != nil { 73 77 return err 74 78 }
+4
spindle/server.go
··· 100 100 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 101 } 102 102 103 + if err := runStartupMigrations(ctx, d, vault, logger); err != nil { 104 + return nil, fmt.Errorf("failed to run startup migrations: %w", err) 105 + } 106 + 103 107 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 108 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 105 109
+80
spindle/startup_migrations.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "log/slog" 8 + 9 + "tangled.org/core/orm" 10 + "tangled.org/core/spindle/db" 11 + "tangled.org/core/spindle/secrets" 12 + ) 13 + 14 + func runStartupMigrations(ctx context.Context, d *db.DB, vault secrets.Manager, logger *slog.Logger) error { 15 + conn, err := d.DB.Conn(ctx) 16 + if err != nil { 17 + return fmt.Errorf("acquire spindle conn: %w", err) 18 + } 19 + defer conn.Close() 20 + 21 + return orm.RunMigration(conn, logger, "copy-owner-rkey-secrets-to-repo-did", func(tx *sql.Tx) error { 22 + return copyOwnerRkeySecretsToRepoDid(ctx, tx, vault, logger) 23 + }) 24 + } 25 + 26 + type repoSecretPair struct { 27 + oldID, newID secrets.RepoIdentifier 28 + } 29 + 30 + func loadRepoSecretPairs(ctx context.Context, tx *sql.Tx) ([]repoSecretPair, error) { 31 + rows, err := tx.QueryContext(ctx, 32 + `select owner, rkey, repo_did from repos 33 + where repo_did is not null and repo_did <> ''`, 34 + ) 35 + if err != nil { 36 + return nil, fmt.Errorf("select repos: %w", err) 37 + } 38 + defer rows.Close() 39 + 40 + var collect func(acc []repoSecretPair) ([]repoSecretPair, error) 41 + collect = func(acc []repoSecretPair) ([]repoSecretPair, error) { 42 + if !rows.Next() { 43 + return acc, rows.Err() 44 + } 45 + var owner, rkey, repoDid string 46 + if err := rows.Scan(&owner, &rkey, &repoDid); err != nil { 47 + return acc, fmt.Errorf("scan repos row: %w", err) 48 + } 49 + return collect(append(acc, repoSecretPair{ 50 + oldID: secrets.RepoIdentifier(owner + "/" + rkey), 51 + newID: secrets.RepoIdentifier(repoDid), 52 + })) 53 + } 54 + return collect(nil) 55 + } 56 + 57 + func copyOwnerRkeySecretsToRepoDid(ctx context.Context, tx *sql.Tx, vault secrets.Manager, logger *slog.Logger) error { 58 + pairs, err := loadRepoSecretPairs(ctx, tx) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + var step func(remaining []repoSecretPair, totalCopied int) error 64 + step = func(remaining []repoSecretPair, totalCopied int) error { 65 + if len(remaining) == 0 { 66 + logger.Info("secret copy migration complete", "rows", len(pairs), "copied", totalCopied) 67 + return nil 68 + } 69 + p := remaining[0] 70 + n, err := copyRepoSecrets(ctx, vault, p.oldID, p.newID) 71 + if err != nil { 72 + return fmt.Errorf("copy %s -> %s: %w", p.oldID, p.newID, err) 73 + } 74 + if n > 0 { 75 + logger.Info("secrets copied", "old", p.oldID, "new", p.newID, "count", n) 76 + } 77 + return step(remaining[1:], totalCopied+n) 78 + } 79 + return step(pairs, 0) 80 + }
+209
spindle/startup_migrations_test.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "log/slog" 7 + "path/filepath" 8 + "testing" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + 13 + "tangled.org/core/spindle/db" 14 + "tangled.org/core/spindle/secrets" 15 + ) 16 + 17 + func newTestSpindleDB(t *testing.T) *db.DB { 18 + t.Helper() 19 + d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) 20 + if err != nil { 21 + t.Fatalf("db.Make: %v", err) 22 + } 23 + t.Cleanup(func() { d.Close() }) 24 + return d 25 + } 26 + 27 + func newTestVault(t *testing.T) *secrets.SqliteManager { 28 + t.Helper() 29 + vault, err := secrets.NewSQLiteManager(filepath.Join(t.TempDir(), "vault.db")) 30 + if err != nil { 31 + t.Fatalf("vault.New: %v", err) 32 + } 33 + return vault 34 + } 35 + 36 + func mustAddRepo(t *testing.T, d *db.DB, knot, owner, rkey, repoDid string) { 37 + t.Helper() 38 + if err := d.AddRepo(db.Repo{ 39 + Knot: knot, 40 + Owner: syntax.DID(owner), 41 + Rkey: syntax.RecordKey(rkey), 42 + RepoDid: syntax.DID(repoDid), 43 + }); err != nil { 44 + t.Fatalf("AddRepo(%s): %v", rkey, err) 45 + } 46 + } 47 + 48 + func mustAddSecret(t *testing.T, vault secrets.Manager, repo, key, value string, createdAt time.Time, by string) { 49 + t.Helper() 50 + err := vault.AddSecret(context.Background(), secrets.UnlockedSecret{ 51 + Repo: secrets.RepoIdentifier(repo), 52 + Key: key, 53 + Value: value, 54 + CreatedAt: createdAt, 55 + CreatedBy: syntax.DID(by), 56 + }) 57 + if err != nil { 58 + t.Fatalf("AddSecret(%s/%s): %v", repo, key, err) 59 + } 60 + } 61 + 62 + func TestStartupMigrations_CopyOwnerRkeySecretsToRepoDid(t *testing.T) { 63 + ctx := context.Background() 64 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 65 + 66 + d := newTestSpindleDB(t) 67 + vault := newTestVault(t) 68 + 69 + owner := "did:plc:akshay" 70 + migratedRepoDid := "did:plc:boltless" 71 + skippedRkey := "3kspindlerkey00b" 72 + migratedRkey := "3kspindlerkey00a" 73 + 74 + mustAddRepo(t, d, "knot.test", owner, migratedRkey, migratedRepoDid) 75 + mustAddRepo(t, d, "knot.test", owner, skippedRkey, "") 76 + 77 + created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 78 + oldRepoKey := owner + "/" + migratedRkey 79 + skippedKey := owner + "/" + skippedRkey 80 + 81 + mustAddSecret(t, vault, oldRepoKey, "API_KEY", "alpha", created, owner) 82 + mustAddSecret(t, vault, oldRepoKey, "DB_PASSWORD", "bravo", created.Add(1*time.Hour), owner) 83 + mustAddSecret(t, vault, skippedKey, "STRAY", "delta", created, owner) 84 + 85 + if err := runStartupMigrations(ctx, d, vault, logger); err != nil { 86 + t.Fatalf("first migration run: %v", err) 87 + } 88 + 89 + copied, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(migratedRepoDid)) 90 + if err != nil { 91 + t.Fatalf("GetSecretsUnlocked(new): %v", err) 92 + } 93 + if len(copied) != 2 { 94 + t.Fatalf("expected 2 secrets under new repo_did key, got %d", len(copied)) 95 + } 96 + 97 + want := map[string]struct { 98 + value string 99 + createdAt time.Time 100 + }{ 101 + "API_KEY": {"alpha", created}, 102 + "DB_PASSWORD": {"bravo", created.Add(1 * time.Hour)}, 103 + } 104 + for _, s := range copied { 105 + w, ok := want[s.Key] 106 + if !ok { 107 + t.Errorf("unexpected key %q under %s", s.Key, migratedRepoDid) 108 + continue 109 + } 110 + if s.Value != w.value { 111 + t.Errorf("%s: value got %q, want %q", s.Key, s.Value, w.value) 112 + } 113 + if !s.CreatedAt.Equal(w.createdAt) { 114 + t.Errorf("%s: CreatedAt got %s, want %s", s.Key, s.CreatedAt, w.createdAt) 115 + } 116 + if string(s.Repo) != migratedRepoDid { 117 + t.Errorf("%s: Repo got %s, want %s", s.Key, s.Repo, migratedRepoDid) 118 + } 119 + } 120 + 121 + orig, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(oldRepoKey)) 122 + if err != nil { 123 + t.Fatalf("GetSecretsUnlocked(old): %v", err) 124 + } 125 + if len(orig) != 2 { 126 + t.Errorf("expected old-key secrets preserved, got %d", len(orig)) 127 + } 128 + 129 + stray, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(skippedKey)) 130 + if err != nil { 131 + t.Fatalf("GetSecretsUnlocked(skipped): %v", err) 132 + } 133 + if len(stray) != 1 { 134 + t.Errorf("expected skipped repo's old-key secret untouched, got %d", len(stray)) 135 + } 136 + 137 + if err := runStartupMigrations(ctx, d, vault, logger); err != nil { 138 + t.Fatalf("second migration run: %v", err) 139 + } 140 + 141 + again, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(migratedRepoDid)) 142 + if err != nil { 143 + t.Fatalf("GetSecretsUnlocked(new) after re-run: %v", err) 144 + } 145 + if len(again) != 2 { 146 + t.Errorf("re-run should not duplicate or drop secrets, got %d", len(again)) 147 + } 148 + 149 + var marked int 150 + if err := d.QueryRow( 151 + `select count(*) from migrations where name = ?`, 152 + "copy-owner-rkey-secrets-to-repo-did", 153 + ).Scan(&marked); err != nil { 154 + t.Fatalf("query migrations: %v", err) 155 + } 156 + if marked != 1 { 157 + t.Errorf("expected migration recorded exactly once, got %d", marked) 158 + } 159 + } 160 + 161 + func TestStartupMigrations_NoRepos(t *testing.T) { 162 + ctx := context.Background() 163 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 164 + d := newTestSpindleDB(t) 165 + vault := newTestVault(t) 166 + 167 + if err := runStartupMigrations(ctx, d, vault, logger); err != nil { 168 + t.Fatalf("migration on empty db: %v", err) 169 + } 170 + } 171 + 172 + func TestStartupMigrations_PartialPreExisting(t *testing.T) { 173 + ctx := context.Background() 174 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 175 + d := newTestSpindleDB(t) 176 + vault := newTestVault(t) 177 + 178 + owner := "did:plc:akshay" 179 + repoDid := "did:plc:boltless" 180 + rkey := "3kspindlerkey00a" 181 + mustAddRepo(t, d, "knot.test", owner, rkey, repoDid) 182 + 183 + created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 184 + oldKey := owner + "/" + rkey 185 + mustAddSecret(t, vault, oldKey, "API_KEY", "alpha", created, owner) 186 + mustAddSecret(t, vault, oldKey, "DB_PASSWORD", "bravo", created, owner) 187 + 188 + mustAddSecret(t, vault, repoDid, "API_KEY", "pre-existing", created.Add(-24*time.Hour), owner) 189 + 190 + if err := runStartupMigrations(ctx, d, vault, logger); err != nil { 191 + t.Fatalf("migration: %v", err) 192 + } 193 + 194 + got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 195 + if err != nil { 196 + t.Fatalf("GetSecretsUnlocked: %v", err) 197 + } 198 + if len(got) != 2 { 199 + t.Fatalf("expected 2 secrets under new key, got %d", len(got)) 200 + } 201 + for _, s := range got { 202 + if s.Key == "API_KEY" && s.Value != "pre-existing" { 203 + t.Errorf("API_KEY should preserve pre-existing value, got %q", s.Value) 204 + } 205 + if s.Key == "DB_PASSWORD" && s.Value != "bravo" { 206 + t.Errorf("DB_PASSWORD should be copied, got %q", s.Value) 207 + } 208 + } 209 + }