carlos / platform Public

Preserve existing app pipelines during console registration repair

codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 7e131c0447116305209cd0e0fa07fc72d2afc6a1 parent c1d40ee
3 files changed, +110 −18
  • .amadan/ci.d/30-minio-contract +33 −9
  • internal/console/apps_claim_test.go +68 −0
  • internal/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
}