Preserve existing app pipelines during console registration repair
3 files changed,
+110
−18
.amadan/ci.d/30-minio-contract+33 −9internal/console/apps_claim_test.go+68 −0internal/console/server.go+9 −9
CI passed — run details
diff --git a/.amadan/ci.d/30-minio-contract b/.amadan/ci.d/30-minio-contract| index 7cf3f8bb4..a93b0bad9 100755 |
| --- a/.amadan/ci.d/30-minio-contract |
| +++ b/.amadan/ci.d/30-minio-contract |
| @@ -20,8 +20,8 @@ |
| # .amadan/README.md used to list this job as "not ported", on the grounds |
| # that it starts MinIO in Docker and amadan jobs get no container |
| # runtime. That reasoning conflated the job with its packaging. MinIO and |
| -# mc are single static Go binaries, published per-arch at pinned archive |
| -# URLs on dl.min.io, and contract_test.go only ever wanted MINIO_ENDPOINT |
| +# mc are single static Go binaries, published per-arch as release assets |
| +# (see "The source" below), and contract_test.go only ever wanted MINIO_ENDPOINT |
| # plus credentials over HTTP — it has no opinion about what is serving. |
| # So the container was never load-bearing; it was just how GitHub happens |
| # to hand you a MinIO. |
| @@ -54,17 +54,41 @@ done |
| # is the same one it downloaded. |
| # |
| # But a version string is a weaker pin than a container digest, which the |
| -# workflow got for free and a plain URL does not: |
| -# dl.min.io could serve different bytes at the same archive path and |
| -# nothing would notice. So each binary carries its sha256, verified after |
| -# download and again on every cache hit, and a mismatch refuses to run |
| -# rather than testing whatever arrived. The four digests below were taken |
| +# workflow got for free and a plain URL does not: whoever serves the file |
| +# could serve different bytes at the same path and nothing would notice. |
| +# So each binary carries its sha256, verified after download and again on |
| +# every cache hit, and a mismatch refuses to run rather than testing |
| +# whatever arrived. The four digests below were taken |
| # from upstream's own published .sha256sum files alongside each archive |
| # (and independently confirmed against the downloaded bytes) on |
| # 2026-08-23. |
| # |
| # Bump the two releases together, and re-derive all four digests when you |
| # do. |
| +# |
| +# THE SOURCE. These used to come from dl.min.io. On 2026-09-15 every |
| +# download URL there — this pinned archive, the "latest" build, the |
| +# .sha256sum files, the archive listing — answers 410 Gone, with a notice |
| +# that the open-source MinIO Server, mc and KES are archived and no longer |
| +# served, for all community releases and hotfix builds. (The site's front |
| +# page still loads; github.com/minio/minio and minio/mc are archived too.) |
| +# |
| +# The same releases are published as GitHub release assets on those |
| +# archived repositories — attached there in April 2026, shortly before the |
| +# archive, not there from the start. The bytes are the bytes pinned here: |
| +# on 2026-09-15 all four were downloaded from GitHub and their sha256 |
| +# matched the four digests below exactly, as did GitHub's own .sha256sum |
| +# files. Those digests were taken from dl.min.io in August, so the two |
| +# sources independently agree. Only the URL moved; the pins, and what a |
| +# mismatch does, did not. |
| +# |
| +# That is a stopgap and should be read as one. An archived project's |
| +# release assets are not a promise, and there will be no further |
| +# releases — no fixes, no security updates. The durable answers are a |
| +# copy of these four files somewhere this deployment controls (the |
| +# digests make any copy as trustworthy as the original), or a different |
| +# S3-compatible server for this contract, which is a larger decision: the |
| +# test exists to prove IAM-policy behaviour against a real server. |
| MINIO_RELEASE=RELEASE.2025-09-07T16-13-09Z |
| MC_RELEASE=RELEASE.2025-08-13T08-35-41Z |
| @@ -140,10 +164,10 @@ fetch_pinned() { |
| } |
| fetch_pinned minio \ |
| - "https://dl.min.io/server/minio/release/linux-$arch/archive/minio.$MINIO_RELEASE" \ |
| + "https://github.com/minio/minio/releases/download/$MINIO_RELEASE/minio.linux-$arch.$MINIO_RELEASE" \ |
| "$minio_sha" "$cache/minio" |
| fetch_pinned mc \ |
| - "https://dl.min.io/client/mc/release/linux-$arch/archive/mc.$MC_RELEASE" \ |
| + "https://github.com/minio/mc/releases/download/$MC_RELEASE/mc.linux-$arch.$MC_RELEASE" \ |
| "$mc_sha" "$cache/mc" |
| minio=$cache/minio |
diff --git a/internal/console/apps_claim_test.go b/internal/console/apps_claim_test.go| index 3d55f3513..73247d7c1 100644 |
| --- a/internal/console/apps_claim_test.go |
| +++ b/internal/console/apps_claim_test.go |
| @@ -1,10 +1,13 @@ |
| package console |
| import ( |
| + "bytes" |
| "context" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/url" |
| + "os" |
| "strings" |
| "testing" |
| "time" |
| @@ -13,6 +16,71 @@ import ( |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| +func TestOwnerClaimPreservesExistingPipeline(t *testing.T) { |
| + for _, state := range []string{"legacy", "custom", "unreadable"} { |
| + t.Run(state, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + const app = "existing-site" |
| + seedBucketApp(t, fx, "bab", app) |
| + key := release.PipelineKey("bab", app) |
| + switch state { |
| + case "custom": |
| + _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", app, []release.PipelineChannel{ |
| + {Name: "preview"}, |
| + {Name: "production", Bake: "24h", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2, ChangeApprovals: 2}}, |
| + }, "paul@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case "unreadable": |
| + if _, err := fx.rel.Put(ctx, key, []byte("invalid signed pipeline"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before, etag, beforeErr := fx.rel.Get(ctx, key) |
| + if state == "legacy" && !errors.Is(beforeErr, os.ErrNotExist) { |
| + t.Fatalf("legacy pipeline should be absent: %v", beforeErr) |
| + } |
| + if state != "legacy" && beforeErr != nil { |
| + t.Fatal(beforeErr) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "claim-existing-site") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": app}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("owner adoption = %d %v", status, out) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, app); err != nil || !has { |
| + t.Fatalf("app registration = %v, %v", has, err) |
| + } |
| + after, afterETag, err := fx.rel.Get(ctx, key) |
| + if state == "legacy" { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("adoption created a pipeline for a legacy app: %v", err) |
| + } |
| + } else if err != nil || !bytes.Equal(before, after) || etag != afterETag { |
| + t.Fatalf("adoption changed existing pipeline: err=%v, etag before=%q after=%q", err, etag, afterETag) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestOwnerClaimNewAppGetsDefaultPipeline(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "claim-fresh-site") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "fresh-site"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("new app = %d %v", status, out) |
| + } |
| + pipeline, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, "bab", "fresh-site") |
| + if err != nil || pipeline == nil { |
| + t.Fatalf("new app pipeline = %v, %v", pipeline, err) |
| + } |
| + if len(pipeline.Channels) != 1 || pipeline.Channels[0].Name != "edge" { |
| + t.Fatalf("new app channels = %#v", pipeline.Channels) |
| + } |
| +} |
| + |
| // seedBucketApp puts an object under an account's own app prefix WITHOUT a |
| // row, which is exactly what a direct-bucket ship does (direct mode skips the |
| // claim) and exactly the state that makes #79 unrecoverable: the objects |
diff --git a/internal/console/server.go b/internal/console/server.go| index c3afb458c..2638c04f4 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3458,6 +3458,7 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // objects. For a non-ops account the legacy prefix belongs to somebody |
| // else's app and must NOT block the name. |
| ownPrefix := release.AppPrefix(acct.Sqid, name) |
| + adoptingExistingApp := false |
| prefixes := []string{ownPrefix} |
| if acct.ID == OpsAccountID { |
| prefixes = append(prefixes, release.LegacyAppPrefix(name)) |
| @@ -3489,6 +3490,7 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // owed an `accounts migrate -from legacy`, and a name handed out |
| // over them puts an app in that migration's way. |
| if ownerClaim && ownerExempt(prefix, ownPrefix) { |
| + adoptingExistingApp = true |
| continue |
| } |
| return claimTaken, "" |
| @@ -3516,15 +3518,13 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // account's own manifest is what the agent materializes, and minting |
| // already wrote it. |
| s.ensureAccountManifest(ctx, acct) |
| - // Default pipeline (release pipelines v2 spec §2): every fresh claim |
| - // gets the one-channel edge default up front, through the ONE place |
| - // both doors funnel through — so neither can end up creating an app |
| - // that skips it. Best-effort, like the placement write above and for |
| - // the same reason: an app that ends up pipeline-less is not broken, it |
| - // is the pre-pipelines world (spec §2, "absence means the legacy |
| - // ladder") — exactly as safe as every app created before this existed. |
| - if _, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, name, release.DefaultPipelineChannels(), addr); err != nil { |
| - log.Printf("console: claim %s/%s: set default pipeline: %v", acct.Sqid, name, err) |
| + // Existing objects belong to an app whose console record is being |
| + // restored. Preserve its pipeline, including absence (the legacy ladder). |
| + // Only a new app gets the default edge pipeline (pipelines v2 spec §2). |
| + if !adoptingExistingApp { |
| + if _, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, name, release.DefaultPipelineChannels(), addr); err != nil { |
| + log.Printf("console: claim %s/%s: set default pipeline: %v", acct.Sqid, name, err) |
| + } |
| } |
| return claimOK, placed |
| } |