release: account-scoped keys with a legacy read-fallback — no flag day
App identity is account-scoped: apps/<acct-sqid>/<app>/… is the key, and the key path IS the app→account linkage (no separate manifest). Ship/Promote/ Channel/Rollback/GetManifest, the edge's static handler, vet, adopt and the console all take the account now. The data moves days after the code, so every READ tries the qualified key and falls back to the legacy unqualified one; every WRITE is qualified only. GetWithFallback reports which key answered — Promote/Rollback need that to know their qualified Put must be create-only when the history came from a legacy pointer. A store error that is not not-found surfaces unchanged: a broken bucket must never read as an unmigrated app. An empty account deliberately produces a broken key rather than the legacy shape, so an unqualified write can never masquerade as a migrated one. Two cross-tenant collisions closed while the keys were open: adopt now groups by (account, app, channel) rather than (app, channel), and the edge's static cache is rooted per account — otherwise two accounts' apps of the same name would resolve one pointer and share one extracted tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 files changed,
+608
−189
cli.go+23 −7e2e_test.go+12 −12internal/console/server.go+45 −27internal/console/server_test.go+4 −4internal/console/view.go+13 −11internal/console/view_test.go+13 −13internal/edge/static.go+20 −6internal/edge/static_test.go+5 −5internal/release/adopt.go+36 −13internal/release/adopt_test.go+13 −13internal/release/bake_test.go+8 −8internal/release/fallback.go+85 −0internal/release/fallback_test.go+190 −0internal/release/release.go+87 −24internal/release/release_test.go+34 −34internal/vet/vet.go+13 −5internal/vet/vet_test.go+7 −7
diff --git a/cli.go b/cli.go| index 02511f882..4aa0ca9ee 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -158,6 +158,17 @@ func runInstances(args []string) { |
| w.Flush() |
| } |
| +// accountFlag registers the -account flag every app verb carries. It holds |
| +// the account SQID the app's bucket keys live under, defaulting to the |
| +// built-in ops account so a single-tenant deployment never types it. |
| +// |
| +// Task 6 makes this accept a name as well (resolved against the registry); |
| +// today the value is used verbatim, and reads fall back to the legacy |
| +// unqualified keys, so an unmigrated app keeps working either way. |
| +func accountFlag(fs *flag.FlagSet) *string { |
| + return fs.String("account", release.OpsAccount, "account sqid the app belongs to") |
| +} |
| + |
| func openStore() release.Store { |
| if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| s, err := release.NewS3Store(context.Background(), b) |
| @@ -192,6 +203,7 @@ func publicKey() ed25519.PublicKey { |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + acct := accountFlag(fs) |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := fs.String("version", "", "version (git short sha)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -241,7 +253,7 @@ func runShip(args []string) { |
| BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339), |
| Artifacts: arts, |
| } |
| - if err := release.Ship(context.Background(), openStore(), m, files); err != nil { |
| + if err := release.Ship(context.Background(), openStore(), *acct, m, files); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files)) |
| @@ -250,11 +262,12 @@ func runShip(args []string) { |
| func runPromote(args []string) { |
| fs := flag.NewFlagSet("promote", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + acct := accountFlag(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 2 { |
| log.Fatal("usage: carlos promote -app <a> <version> <channel>") |
| } |
| - if err := release.Promote(context.Background(), openStore(), signingKey(), *app, fs.Arg(1), fs.Arg(0)); err != nil { |
| + if err := release.Promote(context.Background(), openStore(), signingKey(), *acct, *app, fs.Arg(1), fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("promoted %s %s -> %s\n", *app, fs.Arg(0), fs.Arg(1)) |
| @@ -263,12 +276,14 @@ func runPromote(args []string) { |
| func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + acct := accountFlag(fs) |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos channels -app <a>") |
| + log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>]") |
| } |
| s := openStore() |
| - keys, err := s.List(context.Background(), "apps/"+*app+"/channels/") |
| + keys, prefix, _, err := release.ListWithFallback(context.Background(), s, |
| + release.ChannelsPrefix(*acct, *app), release.LegacyChannelsPrefix(*app)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -276,8 +291,8 @@ func runChannels(args []string) { |
| w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| for _, k := range keys { |
| - name := strings.TrimSuffix(strings.TrimPrefix(k, "apps/"+*app+"/channels/"), ".json") |
| - cp, err := release.Channel(context.Background(), s, pub, *app, name) |
| + name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + cp, err := release.Channel(context.Background(), s, pub, *acct, *app, name) |
| if err != nil { |
| fmt.Fprintf(w, "%s\tERROR: %v\t\n", name, err) |
| continue |
| @@ -290,11 +305,12 @@ func runChannels(args []string) { |
| func runRollback(args []string) { |
| fs := flag.NewFlagSet("rollback", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + acct := accountFlag(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| log.Fatal("usage: carlos rollback -app <a> <channel>") |
| } |
| - if err := release.Rollback(context.Background(), openStore(), signingKey(), *app, fs.Arg(0)); err != nil { |
| + if err := release.Rollback(context.Background(), openStore(), signingKey(), *acct, *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0)) |
diff --git a/e2e_test.go b/e2e_test.go| index c7d8094f2..4debc0ba5 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -80,7 +80,7 @@ func e2eShipSite(t *testing.T, store release.Store, version string, files map[st |
| Schema: 1, App: "site", Version: version, Kind: "static", |
| Artifacts: map[string]release.Artifact{"site.tar.gz": {}}, |
| } |
| - if err := release.Ship(context.Background(), store, m, map[string][]byte{"site.tar.gz": tarball}); err != nil { |
| + if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"site.tar.gz": tarball}); err != nil { |
| t.Fatalf("Ship site %s: %v", version, err) |
| } |
| } |
| @@ -91,7 +91,7 @@ func e2eShipBinary(t *testing.T, store release.Store, version string, bin []byte |
| Schema: 1, App: "echoapp", Version: version, Kind: "binary", |
| Artifacts: map[string]release.Artifact{"echoapp": {}}, |
| } |
| - if err := release.Ship(context.Background(), store, m, map[string][]byte{"echoapp": bin}); err != nil { |
| + if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{"echoapp": bin}); err != nil { |
| t.Fatalf("Ship echoapp %s: %v", version, err) |
| } |
| } |
| @@ -212,18 +212,18 @@ func TestEndToEndSmoke(t *testing.T) { |
| // Ship + promote the binary app and the static site to canary/x. |
| e2eShipBinary(t, store, "v1", binBytes) |
| - if err := release.Promote(ctx, store, priv, "echoapp", "canary/x", "v1"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote echoapp v1: %v", err) |
| } |
| e2eShipSite(t, store, "v1", map[string]string{"index.html": "site v1", "docs/page.html": "docs v1"}) |
| e2eShipSite(t, store, "v2", map[string]string{"index.html": "site v2"}) |
| - if err := release.Promote(ctx, store, priv, "site", "canary/x", "v1"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote site v1: %v", err) |
| } |
| // The shipped binary release passes vet (boot check self-skips where the |
| // sandbox forbids unix listeners). |
| - if probs := vet.Check(ctx, store, pub, "echoapp", "v1", true); len(probs) != 0 { |
| + if probs := vet.Check(ctx, store, pub, release.OpsAccount, "echoapp", "v1", true); len(probs) != 0 { |
| t.Fatalf("vet.Check echoapp v1 = %v, want clean", probs) |
| } |
| @@ -308,16 +308,16 @@ func TestEndToEndSmoke(t *testing.T) { |
| } |
| // (3) Promote advances the served site; rollback swaps it back. |
| - if err := release.Promote(ctx, store, priv, "site", "canary/x", "v2"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { |
| t.Fatalf("Promote site v2: %v", err) |
| } |
| if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v2" { |
| t.Fatalf("after promote, GET site.local / = %d %q, want 200 %q", code, body, "site v2") |
| } |
| - if err := release.Rollback(ctx, store, priv, "site", "canary/x"); err != nil { |
| + if err := release.Rollback(ctx, store, priv, release.OpsAccount, "site", "canary/x"); err != nil { |
| t.Fatalf("Rollback site: %v", err) |
| } |
| - cp, err := release.Channel(ctx, store, pub, "site", "canary/x") |
| + cp, err := release.Channel(ctx, store, pub, release.OpsAccount, "site", "canary/x") |
| if err != nil { |
| t.Fatalf("Channel site after rollback: %v", err) |
| } |
| @@ -432,20 +432,20 @@ func TestEndToEndSmoke(t *testing.T) { |
| // then roll back; the pointer must return to v1 with v2 as the rollback |
| // target. (Applying the pointer on a box is host/agent territory.) |
| e2eShipBinary(t, store, "v2", binBytes) |
| - if err := release.Promote(ctx, store, priv, "echoapp", "canary/x", "v2"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x", "v2"); err != nil { |
| t.Fatalf("Promote echoapp v2: %v", err) |
| } |
| - cp, err = release.Channel(ctx, store, pub, "echoapp", "canary/x") |
| + cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") |
| if err != nil { |
| t.Fatalf("Channel echoapp: %v", err) |
| } |
| if cp.Version != "v2" || len(cp.History) == 0 || cp.History[0] != "v1" { |
| t.Fatalf("promoted pointer = %s (history %v), want v2 over v1", cp.Version, cp.History) |
| } |
| - if err := release.Rollback(ctx, store, priv, "echoapp", "canary/x"); err != nil { |
| + if err := release.Rollback(ctx, store, priv, release.OpsAccount, "echoapp", "canary/x"); err != nil { |
| t.Fatalf("Rollback echoapp: %v", err) |
| } |
| - cp, err = release.Channel(ctx, store, pub, "echoapp", "canary/x") |
| + cp, err = release.Channel(ctx, store, pub, release.OpsAccount, "echoapp", "canary/x") |
| if err != nil { |
| t.Fatalf("Channel echoapp after rollback: %v", err) |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 0a09465e6..22f0aa2c0 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -505,7 +505,7 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if len(apps) > 0 { |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps, s.cfg.EdgeIPs) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, release.OpsAccount, apps, s.cfg.EdgeIPs) |
| if err != nil { |
| log.Printf("console: assemble: %v", err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -521,29 +521,35 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| // ownedApp gates the per-app pages and mutations: session required, and the |
| // path's app must be owned by the signed-in address — not owned and |
| // nonexistent look identical (404) on purpose. |
| -func (s *Server) ownedApp(w http.ResponseWriter, r *http.Request) (addr, raw, app string, ok bool) { |
| +// |
| +// acct is the account sqid whose bucket keys this app's objects live under. |
| +// Every route under this gate is still the account-less /apps/{app} shape, so |
| +// it is the built-in ops account for now; task 5 re-roots these handlers |
| +// under /a/{sqid}/ and the sqid comes from the path. Reads fall back to the |
| +// legacy unqualified keys either way, so nothing breaks in between. |
| +func (s *Server) ownedApp(w http.ResponseWriter, r *http.Request) (addr, raw, acct, app string, ok bool) { |
| addr, raw, sok := s.session(r) |
| if !sok { |
| http.Redirect(w, r, "/", http.StatusFound) |
| - return "", "", "", false |
| + return "", "", "", "", false |
| } |
| app = r.PathValue("app") |
| apps, err := s.store.AppsFor(addr) |
| if err != nil { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| - return "", "", "", false |
| + return "", "", "", "", false |
| } |
| for _, a := range apps { |
| if a == app { |
| - return addr, raw, app, true |
| + return addr, raw, release.OpsAccount, app, true |
| } |
| } |
| s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") |
| - return "", "", "", false |
| + return "", "", "", "", false |
| } |
| func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, app, ok := s.ownedApp(w, r) |
| + addr, raw, acct, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -551,7 +557,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| // feature existed (or CLI-granted ones): best-effort, on the OWNER's own |
| // first view — ownedApp above already proved the viewer owns this app. |
| s.ensureAccountManifest(r.Context(), app, addr) |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}, s.cfg.EdgeIPs) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct, []string{app}, s.cfg.EdgeIPs) |
| if err != nil || len(views) != 1 { |
| log.Printf("console: assemble %s: %v", app, err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -559,7 +565,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| } |
| p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0], CanPromote: s.cfg.Key != nil} |
| p.Flash, p.FlashErr = s.takeFlash(w, r) |
| - versions, err := Versions(s.cfg.Releases, app) |
| + versions, err := Versions(s.cfg.Releases, acct, app) |
| if err != nil { |
| log.Printf("console: versions %s: %v", app, err) |
| p.FlashErr = "Could not list this app's releases." |
| @@ -677,12 +683,24 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
| - if keys, err := s.cfg.Releases.List(ctx, "apps/"+name+"/"); err != nil { |
| - fail("Could not check the release store. Please try again.") |
| - return |
| - } else if len(keys) > 0 { |
| - fail("That name is taken.") |
| - return |
| + // Both prefixes: the account-qualified one a migrated app writes to, and |
| + // the legacy unqualified one an unmigrated app's objects are still at. |
| + // Missing the legacy prefix here would hand out a name whose releases |
| + // already exist — which is precisely the "orphan bucket objects" case this |
| + // check was added for. |
| + for _, prefix := range []string{ |
| + release.AppPrefix(release.OpsAccount, name), |
| + release.LegacyAppPrefix(name), |
| + } { |
| + keys, err := s.cfg.Releases.List(ctx, prefix) |
| + if err != nil { |
| + fail("Could not check the release store. Please try again.") |
| + return |
| + } |
| + if len(keys) > 0 { |
| + fail("That name is taken.") |
| + return |
| + } |
| } |
| created, err := s.store.CreateApp(name, addr, time.Now()) |
| if err != nil { |
| @@ -711,7 +729,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| // handlePromote points a channel at a shipped version, from the app page. |
| func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, app, ok := s.ownedApp(w, r) |
| + addr, raw, acct, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -743,11 +761,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back("err", "Invalid version.") |
| return |
| } |
| - if _, err := release.GetManifest(ctx, s.cfg.Releases, app, version); err != nil { |
| + if _, err := release.GetManifest(ctx, s.cfg.Releases, acct, app, version); err != nil { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| - if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, app, channel, version); err != nil { |
| + if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel, version); err != nil { |
| log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err) |
| back("err", "Promote failed: "+err.Error()) |
| return |
| @@ -762,7 +780,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // handleRollback swaps a channel back to its previous version. |
| func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, app, ok := s.ownedApp(w, r) |
| + addr, raw, acct, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -786,7 +804,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, app, channel) |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, channel) |
| if err != nil { |
| back("err", "Could not read channel "+channel+".") |
| return |
| @@ -796,7 +814,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| - if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, app, channel); err != nil { |
| + if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel); err != nil { |
| log.Printf("console: rollback %s %s: %v", app, channel, err) |
| back("err", "Rollback failed: "+err.Error()) |
| return |
| @@ -848,7 +866,7 @@ func (s *Server) channelHosts(app, channel string) ([]string, error) { |
| // pointed at a chosen shipped version. Canary channels bypass bake by |
| // design, so the canary serves the moment a host follows its channel. |
| func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, app, ok := s.ownedApp(w, r) |
| + addr, raw, acct, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -878,20 +896,20 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - if _, err := release.GetManifest(ctx, s.cfg.Releases, app, version); err != nil { |
| + if _, err := release.GetManifest(ctx, s.cfg.Releases, acct, app, version); err != nil { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| // New means new: an existing canary is promoted to (releases table) or |
| // retired, never quietly re-created. |
| - if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(app, channel)); err == nil { |
| + if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(acct, app, channel)); err == nil { |
| back("err", "Canary "+name+" already exists — promote to it from the releases table, or retire it first.") |
| return |
| } else if !errors.Is(err, os.ErrNotExist) { |
| back("err", "Could not check the release store. Please try again.") |
| return |
| } |
| - if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, app, channel, version); err != nil { |
| + if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, acct, app, channel, version); err != nil { |
| log.Printf("console: canary %s %s -> %s: %v", app, version, channel, err) |
| back("err", "Creating the canary failed: "+err.Error()) |
| return |
| @@ -910,7 +928,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| // handleRetireCanary removes a canary's channel pointer. Shipped releases |
| // are untouched — retirement un-points, it never deletes bits. |
| func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, app, ok := s.ownedApp(w, r) |
| + addr, raw, acct, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -949,7 +967,7 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - key := release.ChannelKey(app, channel) |
| + key := release.ChannelKey(acct, app, channel) |
| if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) { |
| back("err", "No canary named "+name+" here.") |
| return |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 7a7aa6a06..184f47cc1 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -94,7 +94,7 @@ func newConsoleFixtureWith(t *testing.T, withKey bool, wrap func(release.Store) |
| // channelVersion reads a channel pointer straight from the bucket. |
| func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer { |
| t.Helper() |
| - p, err := release.Channel(context.Background(), fx.rel, fx.pub, app, channel) |
| + p, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel) |
| if err != nil { |
| t.Fatalf("channel %s/%s: %v", app, channel, err) |
| } |
| @@ -418,7 +418,7 @@ func TestCreateApp(t *testing.T) { |
| // A second user cannot claim the same name, a CLI-granted app, or an |
| // app with releases already in the bucket — all the same "taken". |
| - if err := release.Ship(context.Background(), fx.rel, release.Manifest{ |
| + if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{ |
| Schema: 1, App: "orphan", Version: "v1", Kind: "binary", |
| Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}}, |
| }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { |
| @@ -688,10 +688,10 @@ func TestRetireCanary(t *testing.T) { |
| if status != http.StatusSeeOther || loc != "/apps/kass" { |
| t.Fatalf("retire = %d -> %q", status, loc) |
| } |
| - if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey("kass", "canary/x")); !errors.Is(err, os.ErrNotExist) { |
| + if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "canary/x")); !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("canary/x pointer still present: %v", err) |
| } |
| - if _, err := release.GetManifest(context.Background(), fx.rel, "kass", "v2"); err != nil { |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "kass", "v2"); err != nil { |
| t.Fatalf("retire touched a shipped release: %v", err) |
| } |
| _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
diff --git a/internal/console/view.go b/internal/console/view.go| index 2ca4e5bf8..07d17c06b 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -144,7 +144,7 @@ func openRegistry(path string) (*registry.Registry, error) { |
| // pub, Domains rows with guidance targeting edgeIPs (nil = unknown). Every |
| // app in apps gets an AppView, even an empty one; per-channel failures |
| // degrade to ChannelView.Err. |
| -func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string, edgeIPs []string) ([]AppView, error) { |
| +func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, edgeIPs []string) ([]AppView, error) { |
| ctx := context.Background() |
| reg, err := openRegistry(regPath) |
| if err != nil { |
| @@ -177,7 +177,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| continue |
| } |
| seen[app] = true |
| - channels := assembleChannels(ctx, s, pub, app, now) |
| + channels := assembleChannels(ctx, s, pub, acct, app, now) |
| out = append(out, AppView{ |
| App: app, |
| Routes: byApp[app], // List orders by host already |
| @@ -214,10 +214,12 @@ func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView { |
| return out |
| } |
| -// assembleChannels lists apps/<app>/channels/ and resolves each pointer. |
| -func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, app string, now time.Time) []ChannelView { |
| - prefix := "apps/" + app + "/channels/" |
| - keys, err := s.List(ctx, prefix) |
| +// assembleChannels lists the app's channel pointers — account-qualified, |
| +// falling back to the legacy unqualified prefix for an app that hasn't been |
| +// migrated yet — and resolves each one. |
| +func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) []ChannelView { |
| + keys, prefix, _, err := release.ListWithFallback(ctx, s, |
| + release.ChannelsPrefix(acct, app), release.LegacyChannelsPrefix(app)) |
| if err != nil { |
| return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}} |
| } |
| @@ -232,7 +234,7 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe |
| sort.Strings(names) |
| var out []ChannelView |
| for _, name := range names { |
| - p, err := release.Channel(ctx, s, pub, app, name) |
| + p, err := release.Channel(ctx, s, pub, acct, app, name) |
| if err != nil { |
| out = append(out, ChannelView{Name: name, Err: err.Error()}) |
| continue |
| @@ -280,10 +282,10 @@ const versionsCap = 20 |
| // apps/<app>/releases/ with each manifest's kind and build stamp — newest |
| // first by built_at (best effort: RFC3339 parses win, then version string), |
| // capped at versionsCap. Unreadable manifests degrade to a row error. |
| -func Versions(s release.Store, app string) ([]VersionView, error) { |
| +func Versions(s release.Store, acct, app string) ([]VersionView, error) { |
| ctx := context.Background() |
| - prefix := "apps/" + app + "/releases/" |
| - keys, err := s.List(ctx, prefix) |
| + keys, prefix, _, err := release.ListWithFallback(ctx, s, |
| + release.ReleasesPrefix(acct, app), release.LegacyReleasesPrefix(app)) |
| if err != nil { |
| return nil, err |
| } |
| @@ -295,7 +297,7 @@ func Versions(s release.Store, app string) ([]VersionView, error) { |
| continue |
| } |
| seen[v] = true |
| - m, err := release.GetManifest(ctx, s, app, v) |
| + m, err := release.GetManifest(ctx, s, acct, app, v) |
| if err != nil { |
| out = append(out, VersionView{Version: v, Err: err.Error()}) |
| continue |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 17ec58918..4fea7b3b4 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -82,14 +82,14 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25 |
| Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: builtAt[v], |
| Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, |
| } |
| - if err := release.Ship(ctx, store, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { |
| + if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { |
| t.Fatal(err) |
| } |
| } |
| - if err := release.Promote(ctx, store, priv, "kass", "stable", "v1"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "stable", "v1"); err != nil { |
| t.Fatal(err) |
| } |
| - if err := release.Promote(ctx, store, priv, "kass", "canary/x", "v2"); err != nil { |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "canary/x", "v2"); err != nil { |
| t.Fatal(err) |
| } |
| return regPath, store, priv, pub |
| @@ -108,7 +108,7 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| func TestAssemble(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -164,7 +164,7 @@ func TestAssembleCanaries(t *testing.T) { |
| Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -186,7 +186,7 @@ func TestAssembleCanaries(t *testing.T) { |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,7 +204,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) { |
| func TestAssembleEmptyAppStillListed(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -219,10 +219,10 @@ func TestAssembleEmptyAppStillListed(t *testing.T) { |
| func TestAssembleDegradesBadChannel(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| // A garbage pointer must become one row's error, never a failed page. |
| - if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil { |
| + if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "beta"), []byte("not a signed pointer"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatalf("Assemble must not fail on a bad channel: %v", err) |
| } |
| @@ -237,7 +237,7 @@ func TestAssembleDegradesBadChannel(t *testing.T) { |
| func TestVersionsNewestFirst(t *testing.T) { |
| _, store, _, _ := buildFixtures(t) |
| - vs, err := Versions(store, "kass") |
| + vs, err := Versions(store, release.OpsAccount, "kass") |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -254,10 +254,10 @@ func TestVersionsNewestFirst(t *testing.T) { |
| func TestVersionsDegradesBadManifest(t *testing.T) { |
| _, store, _, _ := buildFixtures(t) |
| - if _, err := store.Put(context.Background(), "apps/kass/releases/v3/manifest.json", []byte("junk"), ""); err != nil { |
| + if _, err := store.Put(context.Background(), release.ManifestKey(release.OpsAccount, "kass", "v3"), []byte("junk"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - vs, err := Versions(store, "kass") |
| + vs, err := Versions(store, release.OpsAccount, "kass") |
| if err != nil { |
| t.Fatalf("Versions must not fail on one bad manifest: %v", err) |
| } |
| @@ -274,7 +274,7 @@ func TestVersionsDegradesBadManifest(t *testing.T) { |
| func TestVersionsEmptyApp(t *testing.T) { |
| _, store, _, _ := buildFixtures(t) |
| - vs, err := Versions(store, "ghost") |
| + vs, err := Versions(store, release.OpsAccount, "ghost") |
| if err != nil || len(vs) != 0 { |
| t.Fatalf("Versions(ghost) = %+v %v, want empty nil", vs, err) |
| } |
diff --git a/internal/edge/static.go b/internal/edge/static.go| index 29abc854c..585f90791 100644 |
| --- a/internal/edge/static.go |
| +++ b/internal/edge/static.go |
| @@ -105,6 +105,11 @@ func PackDir(dir string) ([]byte, error) { |
| // last pointer check) sits behind mu; requests serve from an immutable |
| // per-version directory, so a swap never races a response in flight. |
| type staticSite struct { |
| + // acct is the account sqid whose bucket keys this app's objects live |
| + // under — part of every key, and part of the cache path: app names are |
| + // unique per ACCOUNT, so two accounts can both have a "docs" static site |
| + // and their extracted trees must never share a directory. |
| + acct string |
| app, channel string |
| store release.Store |
| pub ed25519.PublicKey |
| @@ -125,6 +130,7 @@ func newStaticHandler(rt registry.Route, store release.Store, pub ed25519.Public |
| ch = "stable" |
| } |
| return &staticSite{ |
| + acct: release.RouteAccount(rt), |
| app: rt.App, |
| channel: ch, |
| store: store, |
| @@ -146,7 +152,13 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // dir is where version's tree lives once extracted. |
| func (s *staticSite) dir(version string) string { |
| - return filepath.Join(s.cacheDir, s.app, version) |
| + return filepath.Join(s.appDir(), version) |
| +} |
| + |
| +// appDir is this app's cache root, under its account: app names are unique |
| +// per ACCOUNT, so two accounts' "docs" sites must never share a tree. |
| +func (s *staticSite) appDir() string { |
| + return filepath.Join(s.cacheDir, s.acct, s.app) |
| } |
| // currentDir returns the directory to serve this request from, re-resolving |
| @@ -160,7 +172,7 @@ func (s *staticSite) currentDir(ctx context.Context) (string, error) { |
| if s.version != "" && s.recheck > 0 && time.Since(s.checkedAt) < s.recheck { |
| return s.dir(s.version), nil |
| } |
| - cp, err := release.Channel(ctx, s.store, s.pub, s.app, s.channel) |
| + cp, err := release.Channel(ctx, s.store, s.pub, s.acct, s.app, s.channel) |
| if err != nil { |
| return s.stale(fmt.Errorf("resolve channel: %w", err)) |
| } |
| @@ -200,7 +212,7 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| if _, err := os.Stat(dst); err == nil { |
| return nil // already staged by an earlier check (or a sibling handler) |
| } |
| - m, err := release.GetManifest(ctx, s.store, s.app, version) |
| + m, err := release.GetManifest(ctx, s.store, s.acct, s.app, version) |
| if err != nil { |
| return err |
| } |
| @@ -217,7 +229,9 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| if art == nil { |
| return fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) |
| } |
| - data, _, err := s.store.Get(ctx, release.ArtifactKey(s.app, version, art.File)) |
| + data, _, _, err := release.GetWithFallback(ctx, s.store, |
| + release.ArtifactKey(s.acct, s.app, version, art.File), |
| + release.LegacyArtifactKey(s.app, version, art.File)) |
| if err != nil { |
| return err |
| } |
| @@ -229,7 +243,7 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| if int64(len(data)) != art.Size { |
| return fmt.Errorf("%s size mismatch (manifest %d, stored %d)", siteArtifactFile, art.Size, len(data)) |
| } |
| - appDir := filepath.Join(s.cacheDir, s.app) |
| + appDir := s.appDir() |
| if err := os.MkdirAll(appDir, 0o755); err != nil { |
| return err |
| } |
| @@ -255,7 +269,7 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| // (temp symlink + rename — the same swap versions.go's PointLive uses), so |
| // anything looking at the cache dir sees the version flip as one atomic step. |
| func (s *staticSite) pointCurrent(version string) error { |
| - link := filepath.Join(s.cacheDir, s.app, "current") |
| + link := filepath.Join(s.appDir(), "current") |
| tmp := link + ".tmp" |
| _ = os.Remove(tmp) // a leftover from a crashed swap |
| if err := os.Symlink(version, tmp); err != nil { |
diff --git a/internal/edge/static_test.go b/internal/edge/static_test.go| index 330308763..9f391bc20 100644 |
| --- a/internal/edge/static_test.go |
| +++ b/internal/edge/static_test.go |
| @@ -80,7 +80,7 @@ func shipSite(t *testing.T, store release.Store, app, version string, files map[ |
| Schema: 1, App: app, Version: version, Kind: "static", |
| Artifacts: map[string]release.Artifact{siteArtifactFile: {}}, |
| } |
| - if err := release.Ship(context.Background(), store, m, map[string][]byte{siteArtifactFile: tarball}); err != nil { |
| + if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{siteArtifactFile: tarball}); err != nil { |
| t.Fatalf("Ship %s %s: %v", app, version, err) |
| } |
| } |
| @@ -98,7 +98,7 @@ func staticFixture(t *testing.T) (release.Store, string, ed25519.PrivateKey, *ht |
| "docs/page.html": "docs v1", |
| "style.css": "body{}", |
| }) |
| - if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v1"); err != nil { |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote v1: %v", err) |
| } |
| @@ -140,7 +140,7 @@ func TestChannelAdvanceSwapsContent(t *testing.T) { |
| } |
| shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"}) |
| - if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v2"); err != nil { |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { |
| t.Fatalf("Promote v2: %v", err) |
| } |
| @@ -159,7 +159,7 @@ func TestTamperedTarRejected(t *testing.T) { |
| // Corrupt the stored artifact bytes underneath the store (an attacker or |
| // bit-rot on the bucket): flip one byte in the middle of the tar. |
| - artPath := filepath.Join(storeRoot, filepath.FromSlash(release.ArtifactKey("site", "v1", siteArtifactFile))) |
| + artPath := filepath.Join(storeRoot, filepath.FromSlash(release.ArtifactKey(release.OpsAccount, "site", "v1", siteArtifactFile))) |
| raw, err := os.ReadFile(artPath) |
| if err != nil { |
| t.Fatalf("read stored artifact: %v", err) |
| @@ -204,7 +204,7 @@ func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) { |
| store := release.NewDirStore(storeRoot) |
| priv, pub := testKeys(t) |
| shipSite(t, store, "site", "v1", map[string]string{"index.html": "via the edge"}) |
| - if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v1"); err != nil { |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 87d0baf5c..78ff5b8f1 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -44,25 +44,31 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ |
| return err |
| } |
| - // Resolve every distinct (App, Channel) once. Verification failures kill |
| - // the group (nil entry), not the pass. |
| - type groupKey struct{ app, channel string } |
| + // Resolve every distinct (Account, App, Channel) once. Verification |
| + // failures kill the group (nil entry), not the pass. |
| + // |
| + // The account is part of the group key, not just the app: app names are |
| + // unique WITHIN an account, so two routes named the same app in different |
| + // accounts are two different apps following two different channel |
| + // pointers. Grouping on (app, channel) alone would resolve one of them and |
| + // silently install it on both. |
| + type groupKey struct{ acct, app, channel string } |
| pointers := map[groupKey]*ChannelPointer{} |
| for _, rt := range routes { |
| if !adoptable(rt) { |
| continue |
| } |
| - k := groupKey{rt.App, routeChannel(rt)} |
| + k := groupKey{RouteAccount(rt), rt.App, routeChannel(rt)} |
| if _, done := pointers[k]; done { |
| continue |
| } |
| - ptr, err := Channel(ctx, s, pub, k.app, k.channel) |
| + ptr, err := Channel(ctx, s, pub, k.acct, k.app, k.channel) |
| if err != nil { |
| pointers[k] = nil // resolved-and-failed: don't retry per host |
| if errors.Is(err, os.ErrNotExist) { |
| - fmt.Printf(" %s/%s: channel has no release yet\n", k.app, k.channel) |
| + fmt.Printf(" %s/%s/%s: channel has no release yet\n", k.acct, k.app, k.channel) |
| } else { |
| - fmt.Printf(" %s/%s: channel pointer rejected: %v\n", k.app, k.channel, err) |
| + fmt.Printf(" %s/%s/%s: channel pointer rejected: %v\n", k.acct, k.app, k.channel, err) |
| } |
| continue |
| } |
| @@ -103,11 +109,11 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ |
| hibernating = append(hibernating, rt) |
| continue |
| } |
| - ptr := pointers[groupKey{rt.App, routeChannel(rt)}] |
| + ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}] |
| adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now) |
| } |
| for _, rt := range hibernating { |
| - ptr := pointers[groupKey{rt.App, routeChannel(rt)}] |
| + ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}] |
| // restart=nil: hibernating hosts get the pure repoint — never woken. |
| adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now) |
| } |
| @@ -139,6 +145,22 @@ func routeChannel(rt registry.Route) string { |
| return rt.Channel |
| } |
| +// RouteAccount is the account whose bucket keys a route's app lives under: |
| +// the sqid stamped on the row, else the built-in ops. |
| +// |
| +// The ValidAccount gate is doing migration work, not paranoia. Before the |
| +// multi-account change the account column held an account NAME ("ops", |
| +// "paul@keymail.dev"), and a box can be running this code against a registry |
| +// still carrying those. A name is not a sqid, so it reads as ops — whose keys |
| +// are tried qualified first and then fall back to the legacy unqualified |
| +// shape, which is exactly where an unmigrated app's objects still are. |
| +func RouteAccount(rt registry.Route) string { |
| + if ValidAccount(rt.Account) { |
| + return rt.Account |
| + } |
| + return OpsAccount |
| +} |
| + |
| // adoptHost converges one host onto its channel head ptr (nil = the group's |
| // pointer didn't resolve; skip quietly, the group phase already logged). |
| // restart==nil is the symlink-only path (hibernating hosts, or a caller with |
| @@ -165,7 +187,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver) |
| return |
| } |
| - if err := ensureStaged(ctx, s, optDir, rt.App, ver); err != nil { |
| + if err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver); err != nil { |
| fmt.Printf(" %s: %v\n", rt.Host, err) |
| return |
| } |
| @@ -201,11 +223,11 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| // one pass. The manifest supplies the sha; the bytes are refused unless they |
| // hash to it — the channel pointer's signature plus this gate is the whole |
| // trust chain from operator to disk. |
| -func ensureStaged(ctx context.Context, s Store, optDir, app, ver string) error { |
| +func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) error { |
| if _, err := os.Stat(VersionPath(optDir, ver, app)); err == nil { |
| return nil |
| } |
| - m, err := GetManifest(ctx, s, app, ver) |
| + m, err := GetManifest(ctx, s, acct, app, ver) |
| if err != nil { |
| return fmt.Errorf("manifest for %s/%s: %w", app, ver, err) |
| } |
| @@ -216,7 +238,8 @@ func ensureStaged(ctx context.Context, s Store, optDir, app, ver string) error { |
| // catches up. |
| return fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform) |
| } |
| - body, _, err := s.Get(ctx, ArtifactKey(app, ver, art.File)) |
| + body, _, _, err := GetWithFallback(ctx, s, |
| + ArtifactKey(acct, app, ver, art.File), LegacyArtifactKey(app, ver, art.File)) |
| if err != nil { |
| return fmt.Errorf("download %s: %w", ver, err) |
| } |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index ad98a5847..cbb686b3b 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -36,8 +36,8 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) { |
| reg := testRegistry(t) |
| // canary/* has no bake window, so the pass applies immediately. |
| - shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("build one")) |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| @@ -78,8 +78,8 @@ func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) { |
| reg := testRegistry(t) |
| // Both hosts start on v1 (staged locally), channel head moves to v2. |
| - shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("good")) |
| - shipRelease(t, s, "myapp", "v2", adoptPlatform, []byte("bad")) |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("good")) |
| + shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("bad")) |
| if err := StageArtifact(opt, "v1", "myapp", []byte("good")); err != nil { |
| t.Fatal(err) |
| } |
| @@ -89,7 +89,7 @@ func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v2"); err != nil { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| t.Fatalf("Promote v2: %v", err) |
| } |
| @@ -118,8 +118,8 @@ func TestPassStableHeldByBakeWindow(t *testing.T) { |
| opt := t.TempDir() |
| reg := testRegistry(t) |
| - shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| addRoute(t, reg, "h.example.com", "myapp", "", false) // "" = stable |
| @@ -147,8 +147,8 @@ func TestPassSkipsUnadoptableRoutes(t *testing.T) { |
| opt := t.TempDir() |
| reg := testRegistry(t) |
| - shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| // A static route, an app-less instance, and a mid-provision instance: |
| @@ -184,8 +184,8 @@ func TestPassSkipsPlatformMintedRoutes(t *testing.T) { |
| opt := t.TempDir() |
| reg := testRegistry(t) |
| - shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| // The canonical route and its platform alias, identical but for the flag. |
| @@ -228,8 +228,8 @@ func TestPassMissingPlatformArtifactSkips(t *testing.T) { |
| // Shipped for some other platform only: this box must skip, never |
| // install the wrong arch. |
| - shipRelease(t, s, "myapp", "v1", "plan9-mips", []byte("bin")) |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", "plan9-mips", []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| addRoute(t, reg, "h.example.com", "myapp", "canary/x", false) |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index c1743710d..f94098d15 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -56,17 +56,17 @@ func TestRollbackNeverBakes(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| priv, pub := testKeys(t) |
| - shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| - shipRelease(t, s, "myapp", "v2", "linux-amd64", []byte("two")) |
| + shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one")) |
| + shipRelease(t, s, "bab", "myapp", "v2", "linux-amd64", []byte("two")) |
| st := LoadFirstSeen(filepath.Join(t.TempDir(), "first-seen.json")) |
| t0 := time.Now() |
| // v1 promoted to stable; the box first sees it at t0, bakes, installs. |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| t.Fatalf("Promote v1: %v", err) |
| } |
| - ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| if err != nil { |
| t.Fatalf("Channel: %v", err) |
| } |
| @@ -74,10 +74,10 @@ func TestRollbackNeverBakes(t *testing.T) { |
| // Time passes; v1's window elapses; v2 is promoted and first seen NOW. |
| t1 := t0.Add(pastStableHold) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v2"); err != nil { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v2"); err != nil { |
| t.Fatalf("Promote v2: %v", err) |
| } |
| - ptr, _ = Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, _ = Channel(ctx, s, pub, "bab", "myapp", "stable") |
| st.Observe("myapp", "stable", ptr.Version, t1) |
| if hold := Hold(st, "myapp", "stable", ptr.Version, DefaultHolds, false, t1); hold == "" { |
| t.Fatalf("fresh v2 not held") |
| @@ -86,10 +86,10 @@ func TestRollbackNeverBakes(t *testing.T) { |
| // v2 turns out bad — rollback. The pointer is back at v1, and v1 applies |
| // IMMEDIATELY: its first-seen record is t0, long past the hold. Observe |
| // must not reset it. |
| - if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil { |
| t.Fatalf("Rollback: %v", err) |
| } |
| - ptr, err = Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, err = Channel(ctx, s, pub, "bab", "myapp", "stable") |
| if err != nil { |
| t.Fatalf("Channel after rollback: %v", err) |
| } |
diff --git a/internal/release/fallback.go b/internal/release/fallback.go| new file mode 100644 |
| index 000000000..5d585dd56 |
| --- /dev/null |
| +++ b/internal/release/fallback.go |
| @@ -0,0 +1,85 @@ |
| +// The migration seam. App identity became account-scoped (bucket keys gained |
| +// an account-sqid segment), but the DATA moves days after the CODE lands — |
| +// there is deliberately no flag day. So every read path tries the qualified |
| +// key first and falls back to the legacy unqualified one; every write path |
| +// uses the qualified key only. |
| +// |
| +// This whole file is temporary by design: it is deleted once both deployments |
| +// are migrated and verified (spec §5's gate — every channel pointer resolves |
| +// qualified with the fallback disabled, and one adopt pass is a no-op). |
| +// Deleting the LEGACY OBJECTS is an operator step behind that gate; nothing |
| +// in the code ever removes one. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "regexp" |
| +) |
| + |
| +// OpsAccount is the built-in operator account's sqid — registry id 1 under |
| +// the frozen encoder. It is the default account for CLI-added apps, and what |
| +// a caller that genuinely has no account in hand passes. |
| +const OpsAccount = "bab" |
| + |
| +// acctRe pins an account sqid to what the frozen encoder emits: lowercase |
| +// letters only, at least three of them (the encoder's offset guarantees it). |
| +// It exists so a caller can't smuggle a path segment — or an empty string — |
| +// into a bucket key. |
| +var acctRe = regexp.MustCompile(`^[a-z]{3,16}$`) |
| + |
| +// ValidAccount reports whether a is usable as the account segment of a bucket |
| +// key. |
| +func ValidAccount(a string) bool { return acctRe.MatchString(a) } |
| + |
| +// GetWithFallback reads key, and on NOT-FOUND reads legacy instead. It |
| +// reports which one answered, so callers can log the migration's progress and |
| +// so a writer knows whether it holds an etag for the key it is about to write |
| +// (it does not: a legacy read's etag belongs to a different object, and the |
| +// qualified Put must be create-only). |
| +// |
| +// A store error that is not "not found" is returned as-is and never retried |
| +// against legacy. Masking a broken bucket as a fallback miss would make it |
| +// look like an unmigrated app — and the migration would then happily "verify" |
| +// an app it never actually read. |
| +func GetWithFallback(ctx context.Context, s Store, key, legacy string) (body []byte, etag string, usedLegacy bool, err error) { |
| + body, etag, err = s.Get(ctx, key) |
| + if err == nil { |
| + return body, etag, false, nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return nil, "", false, err |
| + } |
| + body, etag, err = s.Get(ctx, legacy) |
| + if err != nil { |
| + // Both absent: report the miss against the key the caller asked for, |
| + // still os.ErrNotExist-wrapped so errors.Is keeps working. |
| + return nil, "", false, err |
| + } |
| + return body, etag, true, nil |
| +} |
| + |
| +// ListWithFallback lists prefix, and falls back to the legacy prefix when the |
| +// qualified one holds nothing. It returns the prefix that answered so callers |
| +// can trim key names correctly. |
| +// |
| +// "Empty" is the miss condition here rather than an error, because listing a |
| +// prefix with no objects under it is not an error in either store — which |
| +// also means a genuinely empty qualified prefix reads as unmigrated. That is |
| +// the honest reading during the window: an app with nothing under its |
| +// qualified prefix has not moved yet. |
| +func ListWithFallback(ctx context.Context, s Store, prefix, legacy string) (keys []string, prefixUsed string, usedLegacy bool, err error) { |
| + keys, err = s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, "", false, err |
| + } |
| + if len(keys) > 0 { |
| + return keys, prefix, false, nil |
| + } |
| + keys, err = s.List(ctx, legacy) |
| + if err != nil { |
| + return nil, "", false, err |
| + } |
| + return keys, legacy, true, nil |
| +} |
diff --git a/internal/release/fallback_test.go b/internal/release/fallback_test.go| new file mode 100644 |
| index 000000000..4e370321a |
| --- /dev/null |
| +++ b/internal/release/fallback_test.go |
| @@ -0,0 +1,190 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// erroringStore is a Store whose every operation fails with a NON-not-found |
| +// error — the "bucket on fire" case the fallback must never swallow. |
| +type erroringStore struct{ err error } |
| + |
| +func (e *erroringStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", e.err |
| +} |
| +func (e *erroringStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", e.err |
| +} |
| +func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err } |
| + |
| +func TestKeysAreAccountScoped(t *testing.T) { |
| + if got := ChannelKey("kqmv", "kass", "stable"); got != "apps/kqmv/kass/channels/stable.json" { |
| + t.Errorf("ChannelKey = %q", got) |
| + } |
| + if got := ManifestKey("bab", "console", "abc1234"); got != "apps/bab/console/releases/abc1234/manifest.json" { |
| + t.Errorf("ManifestKey = %q", got) |
| + } |
| + if got := ArtifactKey("kqmv", "kass", "v1", "kass"); got != "apps/kqmv/kass/releases/v1/kass" { |
| + t.Errorf("ArtifactKey = %q", got) |
| + } |
| + // The legacy shapes stay reachable by name, for the fallback and the |
| + // migration verb only. |
| + if got := LegacyChannelKey("kass", "stable"); got != "apps/kass/channels/stable.json" { |
| + t.Errorf("LegacyChannelKey = %q", got) |
| + } |
| + if got := LegacyManifestKey("console", "abc1234"); got != "apps/console/releases/abc1234/manifest.json" { |
| + t.Errorf("LegacyManifestKey = %q", got) |
| + } |
| + if got := LegacyArtifactKey("kass", "v1", "kass"); got != "apps/kass/releases/v1/kass" { |
| + t.Errorf("LegacyArtifactKey = %q", got) |
| + } |
| +} |
| + |
| +// An empty account must never silently rebuild a legacy key: that would make |
| +// an unqualified write look like a successful qualified one, and the |
| +// migration would then never notice the app had not moved. |
| +func TestEmptyAccountDoesNotRebuildALegacyKey(t *testing.T) { |
| + if got := ChannelKey("", "kass", "stable"); got == LegacyChannelKey("kass", "stable") { |
| + t.Errorf("ChannelKey(\"\", ...) = %q — an empty account rebuilt the legacy key", got) |
| + } |
| +} |
| + |
| +func TestGetWithFallbackPrefersQualifiedThenFallsBack(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := s.Put(ctx, "apps/kass/channels/stable.json", []byte("legacy"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, used, err := GetWithFallback(ctx, s, "apps/kqmv/kass/channels/stable.json", "apps/kass/channels/stable.json") |
| + if err != nil || string(body) != "legacy" || !used { |
| + t.Fatalf("fallback: body=%q used=%v err=%v", body, used, err) |
| + } |
| + if _, err := s.Put(ctx, "apps/kqmv/kass/channels/stable.json", []byte("qualified"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, used, err = GetWithFallback(ctx, s, "apps/kqmv/kass/channels/stable.json", "apps/kass/channels/stable.json") |
| + if err != nil || string(body) != "qualified" || used { |
| + t.Fatalf("qualified must win: body=%q used=%v err=%v", body, used, err) |
| + } |
| +} |
| + |
| +// A store error that is NOT not-found must surface, never be masked as a |
| +// fallback miss — masking would make a broken bucket look like an unmigrated |
| +// app, and the migration's verification gate would then pass on a lie. |
| +func TestGetWithFallbackPropagatesRealErrors(t *testing.T) { |
| + s := &erroringStore{err: errors.New("bucket on fire")} |
| + _, _, _, err := GetWithFallback(context.Background(), s, "a", "b") |
| + if err == nil || !strings.Contains(err.Error(), "on fire") { |
| + t.Fatalf("want the real error, got %v", err) |
| + } |
| +} |
| + |
| +// Neither key present is an honest not-found, so callers can keep using |
| +// errors.Is(err, os.ErrNotExist) as "this app has never shipped here". |
| +func TestGetWithFallbackMissesAsNotExist(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + _, _, _, err := GetWithFallback(context.Background(), s, "apps/kqmv/kass/x.json", "apps/kass/x.json") |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("both-absent err = %v, want os.ErrNotExist-wrapped", err) |
| + } |
| +} |
| + |
| +func TestListWithFallback(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := s.Put(ctx, "apps/kass/channels/stable.json", []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, prefix, used, err := ListWithFallback(ctx, s, "apps/kqmv/kass/channels/", "apps/kass/channels/") |
| + if err != nil || len(keys) != 1 || !used || prefix != "apps/kass/channels/" { |
| + t.Fatalf("fallback list = %v prefix=%q used=%v err=%v", keys, prefix, used, err) |
| + } |
| + if _, err := s.Put(ctx, "apps/kqmv/kass/channels/beta.json", []byte("y"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, prefix, used, err = ListWithFallback(ctx, s, "apps/kqmv/kass/channels/", "apps/kass/channels/") |
| + if err != nil || len(keys) != 1 || used || prefix != "apps/kqmv/kass/channels/" { |
| + t.Fatalf("qualified list must win: %v prefix=%q used=%v err=%v", keys, prefix, used, err) |
| + } |
| +} |
| + |
| +// Promote against an app whose pointer still lives at the legacy key must |
| +// carry that pointer's history forward AND land the new pointer at the |
| +// QUALIFIED key — the read falls back, the write never does. |
| +func TestPromoteReadsLegacyPointerAndWritesQualified(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + // Ship two versions under the qualified prefix, then hand-place a legacy |
| + // pointer at v1 (the shape a pre-migration bucket is in). |
| + shipRelease(t, s, "kqmv", "myapp", "v1", "linux-amd64", []byte("v1")) |
| + shipRelease(t, s, "kqmv", "myapp", "v2", "linux-amd64", []byte("v2")) |
| + legacy, err := signPointer(priv, &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, LegacyChannelKey("myapp", "stable"), legacy, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := Promote(ctx, s, priv, "kqmv", "myapp", "stable", "v2"); err != nil { |
| + t.Fatalf("Promote over a legacy pointer: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, ChannelKey("kqmv", "myapp", "stable")); err != nil { |
| + t.Fatalf("promote did not write the qualified key: %v", err) |
| + } |
| + ptr, err := Channel(ctx, s, pub, "kqmv", "myapp", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ptr.Version != "v2" || len(ptr.History) == 0 || ptr.History[0] != "v1" { |
| + t.Fatalf("pointer = %s history %v, want v2 over v1 (legacy history carried forward)", ptr.Version, ptr.History) |
| + } |
| + // Nothing in this plan deletes a legacy object: the old pointer is still |
| + // there, now shadowed by the qualified one. |
| + if _, _, err := s.Get(ctx, LegacyChannelKey("myapp", "stable")); err != nil { |
| + t.Errorf("promote deleted the legacy pointer: %v", err) |
| + } |
| +} |
| + |
| +// The whole point of the fallback: an app that has never been migrated still |
| +// resolves its channel and its manifest through the account-scoped API. |
| +func TestChannelAndManifestFallBackToLegacyKeys(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + // A wholly legacy app: manifest, artifact and pointer all unqualified. |
| + m := Manifest{Schema: 1, App: "old", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-amd64": {File: "old", SHA256: etagOf([]byte("bin")), Size: 3}}} |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, LegacyArtifactKey("old", "v1", "old"), []byte("bin"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, LegacyManifestKey("old", "v1"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + ptr, err := signPointer(priv, &ChannelPointer{App: "old", Channel: "stable", Version: "v1"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, LegacyChannelKey("old", "stable"), ptr, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, err := GetManifest(ctx, s, "bab", "old", "v1"); err != nil { |
| + t.Fatalf("GetManifest did not fall back: %v", err) |
| + } |
| + got, err := Channel(ctx, s, pub, "bab", "old", "stable") |
| + if err != nil || got.Version != "v1" { |
| + t.Fatalf("Channel did not fall back: %+v %v", got, err) |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| index 34e58cc7d..1ffada312 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -81,17 +81,56 @@ type ChannelPointer struct { |
| // historyMax bounds a pointer's rollback history. |
| const historyMax = 10 |
| -// Bucket keys. Channel names may contain one "/" (canary/<branch>), which |
| -// simply nests the pointer one level deeper. |
| -func ManifestKey(app, version string) string { |
| - return "apps/" + app + "/releases/" + version + "/manifest.json" |
| +// Bucket keys, account-scoped: app names are unique WITHIN an account, so the |
| +// account sqid is part of every key and the key path itself carries the |
| +// app→account linkage (there is no separate linkage manifest). Channel names |
| +// may contain one "/" (canary/<branch>), which simply nests the pointer one |
| +// level deeper. |
| +// |
| +// acct is the account's sqid — "bab" for the built-in ops account. An empty |
| +// acct is INVALID and deliberately produces a broken key ("apps//kass/…") |
| +// rather than the legacy unqualified shape: silently rebuilding a legacy key |
| +// would make an unmigrated app look migrated, which is exactly the lie the |
| +// migration's verification gate exists to catch. |
| +func ManifestKey(acct, app, version string) string { |
| + return appPrefix(acct, app) + "releases/" + version + "/manifest.json" |
| +} |
| +func ArtifactKey(acct, app, version, file string) string { |
| + return appPrefix(acct, app) + "releases/" + version + "/" + file |
| +} |
| +func ChannelKey(acct, app, channel string) string { |
| + return appPrefix(acct, app) + "channels/" + channel + ".json" |
| +} |
| + |
| +// ChannelsPrefix and ReleasesPrefix are the list prefixes behind "what |
| +// channels/releases does this app have" — the console and the CLI list them. |
| +func ChannelsPrefix(acct, app string) string { return appPrefix(acct, app) + "channels/" } |
| +func ReleasesPrefix(acct, app string) string { return appPrefix(acct, app) + "releases/" } |
| + |
| +// AppPrefix is everything an app owns in the bucket, under its account. |
| +func AppPrefix(acct, app string) string { return appPrefix(acct, app) } |
| + |
| +func appPrefix(acct, app string) string { return "apps/" + acct + "/" + app + "/" } |
| + |
| +// The legacy, account-less key shapes. They exist for exactly two callers: |
| +// the read-fallback (fallback.go) and the migration verb, which copies from |
| +// here to there. Nothing writes them any more. |
| +func LegacyManifestKey(app, version string) string { |
| + return legacyAppPrefix(app) + "releases/" + version + "/manifest.json" |
| } |
| -func ArtifactKey(app, version, file string) string { |
| - return "apps/" + app + "/releases/" + version + "/" + file |
| +func LegacyArtifactKey(app, version, file string) string { |
| + return legacyAppPrefix(app) + "releases/" + version + "/" + file |
| } |
| -func ChannelKey(app, channel string) string { |
| - return "apps/" + app + "/channels/" + channel + ".json" |
| +func LegacyChannelKey(app, channel string) string { |
| + return legacyAppPrefix(app) + "channels/" + channel + ".json" |
| } |
| +func LegacyChannelsPrefix(app string) string { return legacyAppPrefix(app) + "channels/" } |
| +func LegacyReleasesPrefix(app string) string { return legacyAppPrefix(app) + "releases/" } |
| + |
| +// LegacyAppPrefix is everything an unmigrated app owns in the bucket. |
| +func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) } |
| + |
| +func legacyAppPrefix(app string) string { return "apps/" + app + "/" } |
| // appRe pins app names to the boring lowercase words the box maps to |
| // /opt/carlos paths — anything fancier is a manifest bug, not a feature. |
| @@ -292,7 +331,10 @@ func ParsePublicKey(b64, src string) (ed25519.PublicKey, error) { |
| // files is keyed by artifact map key ("linux-arm64", "site", ...). Ship |
| // fills each Artifact's SHA256/Size from the bytes (and, when already set, |
| // requires them to match); an empty Artifact.File defaults to the map key. |
| -func Ship(ctx context.Context, s Store, m Manifest, files map[string][]byte) error { |
| +func Ship(ctx context.Context, s Store, acct string, m Manifest, files map[string][]byte) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("ship: invalid account %q", acct) |
| + } |
| m.Schema = 1 |
| for k, a := range m.Artifacts { |
| b, ok := files[k] |
| @@ -319,7 +361,7 @@ func Ship(ctx context.Context, s Store, m Manifest, files map[string][]byte) err |
| return fmt.Errorf("ship: %w", err) |
| } |
| for k, a := range m.Artifacts { |
| - if err := putImmutable(ctx, s, ArtifactKey(m.App, m.Version, a.File), files[k]); err != nil { |
| + if err := putImmutable(ctx, s, ArtifactKey(acct, m.App, m.Version, a.File), files[k]); err != nil { |
| return fmt.Errorf("ship: artifact %s: %w", k, err) |
| } |
| } |
| @@ -327,7 +369,7 @@ func Ship(ctx context.Context, s Store, m Manifest, files map[string][]byte) err |
| if err != nil { |
| return err |
| } |
| - if err := putImmutable(ctx, s, ManifestKey(m.App, m.Version), raw); err != nil { |
| + if err := putImmutable(ctx, s, ManifestKey(acct, m.App, m.Version), raw); err != nil { |
| return fmt.Errorf("ship: manifest: %w", err) |
| } |
| return nil |
| @@ -352,9 +394,12 @@ func putImmutable(ctx context.Context, s Store, key string, body []byte) error { |
| } |
| // GetManifest fetches and validates a release manifest, requiring it to name |
| -// the app/version its key claims. Not-found is os.ErrNotExist-wrapped. |
| -func GetManifest(ctx context.Context, s Store, app, version string) (*Manifest, error) { |
| - raw, _, err := s.Get(ctx, ManifestKey(app, version)) |
| +// the app/version its key claims. Reads the account-qualified key, falling |
| +// back to the legacy unqualified one for an app that hasn't been migrated |
| +// yet. Not-found is os.ErrNotExist-wrapped. |
| +func GetManifest(ctx context.Context, s Store, acct, app, version string) (*Manifest, error) { |
| + raw, _, _, err := GetWithFallback(ctx, s, |
| + ManifestKey(acct, app, version), LegacyManifestKey(app, version)) |
| if err != nil { |
| return nil, err |
| } |
| @@ -366,7 +411,7 @@ func GetManifest(ctx context.Context, s Store, app, version string) (*Manifest, |
| return nil, fmt.Errorf("manifest %s/%s: %w", app, version, err) |
| } |
| if m.App != app || m.Version != version { |
| - return nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(app, version), m.App, m.Version) |
| + return nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(acct, app, version), m.App, m.Version) |
| } |
| return &m, nil |
| } |
| @@ -378,7 +423,10 @@ func GetManifest(ctx context.Context, s Store, app, version string) (*Manifest, |
| // consumes. An existing pointer must verify against this key's public half |
| // before it is replaced: history from a document we can't trust doesn't get |
| // carried forward. |
| -func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel, version string) error { |
| +func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("promote: invalid account %q", acct) |
| + } |
| if !ValidApp(app) { |
| return fmt.Errorf("promote: invalid app %q", app) |
| } |
| @@ -389,12 +437,12 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel |
| return fmt.Errorf("promote: invalid version %q", version) |
| } |
| // A channel only ever points at a version that was actually shipped. |
| - if _, err := GetManifest(ctx, s, app, version); err != nil { |
| + if _, err := GetManifest(ctx, s, acct, app, version); err != nil { |
| return fmt.Errorf("promote: version %s is not shipped: %w", version, err) |
| } |
| pub, _ := priv.Public().(ed25519.PublicKey) |
| - key := ChannelKey(app, channel) |
| - cur, etag, err := s.Get(ctx, key) |
| + key := ChannelKey(acct, app, channel) |
| + cur, etag, usedLegacy, err := GetWithFallback(ctx, s, key, LegacyChannelKey(app, channel)) |
| var old *ChannelPointer |
| switch { |
| case err == nil: |
| @@ -402,6 +450,14 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel |
| if err != nil { |
| return fmt.Errorf("promote: existing pointer at %s: %w", key, err) |
| } |
| + // A legacy read's etag belongs to a DIFFERENT object, so the qualified |
| + // Put must be create-only. The history still comes forward: promoting |
| + // an unmigrated app moves it to the qualified key without losing its |
| + // rollback targets, and leaves the legacy object untouched (shadowed, |
| + // not deleted — deletion is the operator's gated step). |
| + if usedLegacy { |
| + etag = "" |
| + } |
| case errors.Is(err, os.ErrNotExist): |
| old, etag = &ChannelPointer{}, "" // first promote: create-only Put |
| default: |
| @@ -422,8 +478,9 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel |
| // Channel resolves a channel's current pointer, verifying its signature — |
| // the one read path the fleet uses, so nothing unsigned ever reaches a box. |
| // Not-found is os.ErrNotExist-wrapped. |
| -func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, app, channel string) (*ChannelPointer, error) { |
| - raw, _, err := s.Get(ctx, ChannelKey(app, channel)) |
| +func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, channel string) (*ChannelPointer, error) { |
| + raw, _, _, err := GetWithFallback(ctx, s, |
| + ChannelKey(acct, app, channel), LegacyChannelKey(app, channel)) |
| if err != nil { |
| return nil, err |
| } |
| @@ -436,13 +493,16 @@ func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, app, channel s |
| // target at this channel's head has a first-seen record older than the hold |
| // (that's how it got installed the first time), so evacuating a bad release |
| // never itself sits in a bake window (bake.go). |
| -func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel string) error { |
| +func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel string) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("rollback: invalid account %q", acct) |
| + } |
| if !ValidApp(app) || !ValidChannel(channel) { |
| return fmt.Errorf("rollback: invalid app/channel %s/%s", app, channel) |
| } |
| pub, _ := priv.Public().(ed25519.PublicKey) |
| - key := ChannelKey(app, channel) |
| - cur, etag, err := s.Get(ctx, key) |
| + key := ChannelKey(acct, app, channel) |
| + cur, etag, usedLegacy, err := GetWithFallback(ctx, s, key, LegacyChannelKey(app, channel)) |
| if err != nil { |
| return fmt.Errorf("rollback: %w", err) |
| } |
| @@ -450,6 +510,9 @@ func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channe |
| if err != nil { |
| return fmt.Errorf("rollback: existing pointer at %s: %w", key, err) |
| } |
| + if usedLegacy { |
| + etag = "" // the qualified key doesn't exist yet: create-only (see Promote) |
| + } |
| if len(old.History) == 0 { |
| return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel) |
| } |
diff --git a/internal/release/release_test.go b/internal/release/release_test.go| index e358a422d..af5c038ab 100644 |
| --- a/internal/release/release_test.go |
| +++ b/internal/release/release_test.go |
| @@ -31,7 +31,7 @@ func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| } |
| // shipRelease ships a one-artifact binary release keyed by plat. |
| -func shipRelease(t *testing.T, s Store, app, version, plat string, body []byte) { |
| +func shipRelease(t *testing.T, s Store, acct, app, version, plat string, body []byte) { |
| t.Helper() |
| m := Manifest{ |
| App: app, |
| @@ -39,7 +39,7 @@ func shipRelease(t *testing.T, s Store, app, version, plat string, body []byte) |
| Kind: "binary", |
| Artifacts: map[string]Artifact{plat: {}}, |
| } |
| - if err := Ship(context.Background(), s, m, map[string][]byte{plat: body}); err != nil { |
| + if err := Ship(context.Background(), s, acct, m, map[string][]byte{plat: body}); err != nil { |
| t.Fatalf("Ship %s/%s: %v", app, version, err) |
| } |
| } |
| @@ -49,11 +49,11 @@ func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| ctx := context.Background() |
| priv, pub := testKeys(t) |
| - shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("binary bytes v1")) |
| - if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1abc"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("binary bytes v1")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1abc"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| - ptr, err := Channel(ctx, s, pub, "myapp", "canary/x") |
| + ptr, err := Channel(ctx, s, pub, "bab", "myapp", "canary/x") |
| if err != nil { |
| t.Fatalf("Channel: %v", err) |
| } |
| @@ -64,7 +64,7 @@ func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| t.Fatalf("pointer = %+v, want stamped PromotedAt and empty history", ptr) |
| } |
| // The manifest round-trips with the sha256 Ship computed. |
| - m, err := GetManifest(ctx, s, "myapp", "v1abc") |
| + m, err := GetManifest(ctx, s, "bab", "myapp", "v1abc") |
| if err != nil { |
| t.Fatalf("GetManifest: %v", err) |
| } |
| @@ -72,7 +72,7 @@ func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| if art.SHA256 != etagOf([]byte("binary bytes v1")) || art.Size != int64(len("binary bytes v1")) { |
| t.Fatalf("artifact = %+v", art) |
| } |
| - body, _, err := s.Get(ctx, ArtifactKey("myapp", "v1abc", art.File)) |
| + body, _, err := s.Get(ctx, ArtifactKey("bab", "myapp", "v1abc", art.File)) |
| if err != nil || string(body) != "binary bytes v1" { |
| t.Fatalf("artifact bytes = %q, %v", body, err) |
| } |
| @@ -81,12 +81,12 @@ func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| func TestShipImmutable(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| // Re-shipping identical bytes is an idempotent no-op... |
| - shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("same")) |
| - shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("same")) |
| + shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("same")) |
| + shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("same")) |
| // ...but the same version with DIFFERENT content is refused. |
| m := Manifest{App: "myapp", Version: "v1abc", Kind: "binary", |
| Artifacts: map[string]Artifact{"linux-amd64": {}}} |
| - err := Ship(context.Background(), s, m, map[string][]byte{"linux-amd64": []byte("different")}) |
| + err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("different")}) |
| if err == nil { |
| t.Fatalf("Ship accepted different content under an existing version") |
| } |
| @@ -95,7 +95,7 @@ func TestShipImmutable(t *testing.T) { |
| func TestPromoteRequiresShippedVersion(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| priv, _ := testKeys(t) |
| - err := Promote(context.Background(), s, priv, "myapp", "stable", "ghost1") |
| + err := Promote(context.Background(), s, priv, "bab", "myapp", "stable", "ghost1") |
| if err == nil || !errors.Is(err, os.ErrNotExist) { |
| t.Fatalf("Promote of unshipped version: err = %v, want os.ErrNotExist-wrapped", err) |
| } |
| @@ -105,12 +105,12 @@ func TestTamperedPointerRejected(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| priv, pub := testKeys(t) |
| - shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("bin")) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v1abc"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1abc"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| - key := ChannelKey("myapp", "stable") |
| + key := ChannelKey("bab", "myapp", "stable") |
| raw, etag, err := s.Get(ctx, key) |
| if err != nil { |
| t.Fatalf("Get pointer: %v", err) |
| @@ -131,7 +131,7 @@ func TestTamperedPointerRejected(t *testing.T) { |
| if _, err := s.Put(ctx, key, tampered, etag); err != nil { |
| t.Fatalf("plant tampered pointer: %v", err) |
| } |
| - if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| t.Fatalf("Channel accepted a pointer with a flipped signature byte") |
| } |
| @@ -149,22 +149,22 @@ func TestTamperedPointerRejected(t *testing.T) { |
| if _, err := s.Put(ctx, key, tampered2, etag2); err != nil { |
| t.Fatalf("plant tampered payload: %v", err) |
| } |
| - if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| t.Fatalf("Channel accepted a pointer with a flipped payload byte") |
| } |
| // And a validly-signed pointer copied from ANOTHER channel's key is a |
| // cross-channel replay — rejected because app/channel live under the |
| // signature. |
| - if err := Promote(ctx, s, priv, "myapp", "canary/evil", "v1abc"); err != nil { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/evil", "v1abc"); err != nil { |
| t.Fatalf("Promote canary: %v", err) |
| } |
| - canaryRaw, _, _ := s.Get(ctx, ChannelKey("myapp", "canary/evil")) |
| + canaryRaw, _, _ := s.Get(ctx, ChannelKey("bab", "myapp", "canary/evil")) |
| _, curEtag, _ := s.Get(ctx, key) |
| if _, err := s.Put(ctx, key, canaryRaw, curEtag); err != nil { |
| t.Fatalf("plant replayed pointer: %v", err) |
| } |
| - if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| t.Fatalf("Channel accepted a pointer replayed from another channel") |
| } |
| } |
| @@ -175,12 +175,12 @@ func TestPromoteHistoryCapTen(t *testing.T) { |
| priv, pub := testKeys(t) |
| for i := 1; i <= 12; i++ { |
| v := fmt.Sprintf("v%02d", i) |
| - shipRelease(t, s, "myapp", v, "linux-amd64", []byte("bin "+v)) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", v); err != nil { |
| + shipRelease(t, s, "bab", "myapp", v, "linux-amd64", []byte("bin "+v)) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", v); err != nil { |
| t.Fatalf("Promote %s: %v", v, err) |
| } |
| } |
| - ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| if err != nil { |
| t.Fatalf("Channel: %v", err) |
| } |
| @@ -199,24 +199,24 @@ func TestRollbackSwapsAndReturns(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| priv, pub := testKeys(t) |
| - shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| - shipRelease(t, s, "myapp", "v2", "linux-amd64", []byte("two")) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one")) |
| + shipRelease(t, s, "bab", "myapp", "v2", "linux-amd64", []byte("two")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| t.Fatalf("Promote v1: %v", err) |
| } |
| // Nothing to roll back to yet. |
| - if err := Rollback(ctx, s, priv, "myapp", "stable"); err == nil { |
| + if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err == nil { |
| t.Fatalf("Rollback with empty history succeeded") |
| } |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v2"); err != nil { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v2"); err != nil { |
| t.Fatalf("Promote v2: %v", err) |
| } |
| - if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil { |
| t.Fatalf("Rollback: %v", err) |
| } |
| - ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| if err != nil { |
| t.Fatalf("Channel: %v", err) |
| } |
| @@ -224,10 +224,10 @@ func TestRollbackSwapsAndReturns(t *testing.T) { |
| t.Fatalf("after rollback: %+v, want v1 with history [v2]", ptr) |
| } |
| // A second rollback returns to where you were (the swap semantics). |
| - if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil { |
| t.Fatalf("second Rollback: %v", err) |
| } |
| - ptr, _ = Channel(ctx, s, pub, "myapp", "stable") |
| + ptr, _ = Channel(ctx, s, pub, "bab", "myapp", "stable") |
| if ptr.Version != "v2" || len(ptr.History) != 1 || ptr.History[0] != "v1" { |
| t.Fatalf("after second rollback: %+v, want v2 with history [v1]", ptr) |
| } |
| @@ -238,11 +238,11 @@ func TestChannelWrongKeyRejected(t *testing.T) { |
| ctx := context.Background() |
| priv, _ := testKeys(t) |
| _, otherPub := testKeys(t) |
| - shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| - if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| t.Fatalf("Promote: %v", err) |
| } |
| - if _, err := Channel(ctx, s, otherPub, "myapp", "stable"); err == nil { |
| + if _, err := Channel(ctx, s, otherPub, "bab", "myapp", "stable"); err == nil { |
| t.Fatalf("Channel verified against the wrong public key") |
| } |
| } |
diff --git a/internal/vet/vet.go b/internal/vet/vet.go| index 164cda2f1..50b635fc5 100644 |
| --- a/internal/vet/vet.go |
| +++ b/internal/vet/vet.go |
| @@ -45,9 +45,9 @@ var bootTimeout = 10 * time.Second |
| // `-socket <tmp>`, and polled for a 200 on GET /healthz for up to 10s; when |
| // the environment can't listen on unix sockets (sandboxes), the boot check |
| // is skipped with a log line rather than reported as a failure. |
| -func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, app, version string, boot bool) []string { |
| +func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app, version string, boot bool) []string { |
| _ = pub // reserved: manifests are content-addressed, nothing to verify yet |
| - m, err := release.GetManifest(ctx, s, app, version) |
| + m, err := release.GetManifest(ctx, s, acct, app, version) |
| if err != nil { |
| return []string{fmt.Sprintf("manifest: %v", err)} |
| } |
| @@ -64,7 +64,9 @@ func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, app, ver |
| platform := runtime.GOOS + "-" + runtime.GOARCH |
| for _, k := range keys { |
| a := m.Artifacts[k] |
| - b, _, err := s.Get(ctx, release.ArtifactKey(app, version, a.File)) |
| + b, _, _, err := release.GetWithFallback(ctx, s, |
| + release.ArtifactKey(acct, app, version, a.File), |
| + release.LegacyArtifactKey(app, version, a.File)) |
| if err != nil { |
| problems = append(problems, fmt.Sprintf("artifact %s (%s): %v", k, a.File, err)) |
| continue |
| @@ -182,11 +184,17 @@ func bootHealthz(ctx context.Context, bin []byte) error { |
| func Run(args []string) { |
| fs := flag.NewFlagSet("vet", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| + // Sqid only, deliberately: vet has no -registry flag to resolve a name |
| + // against, and guessing an account would vet the wrong app's bytes. |
| + acct := fs.String("account", release.OpsAccount, "account sqid the app lives under") |
| ver := fs.String("version", "", "shipped version to vet") |
| boot := fs.Bool("boot", false, "also boot the binary with -socket and poll /healthz (binary kind)") |
| _ = fs.Parse(args) |
| if *app == "" || *ver == "" { |
| - log.Fatal("usage: carlos vet -app <a> -version <v> [-boot]") |
| + log.Fatal("usage: carlos vet -app <a> -version <v> [-account <sqid>] [-boot]") |
| + } |
| + if !release.ValidAccount(*acct) { |
| + log.Fatalf("vet: invalid account sqid %q", *acct) |
| } |
| s := openStore() |
| var pub ed25519.PublicKey |
| @@ -197,7 +205,7 @@ func Run(args []string) { |
| } |
| pub = p |
| } |
| - problems := Check(context.Background(), s, pub, *app, *ver, *boot) |
| + problems := Check(context.Background(), s, pub, *acct, *app, *ver, *boot) |
| if len(problems) == 0 { |
| fmt.Printf("vet: %s %s OK\n", *app, *ver) |
| return |
diff --git a/internal/vet/vet_test.go b/internal/vet/vet_test.go| index f595ce62a..0286161c8 100644 |
| --- a/internal/vet/vet_test.go |
| +++ b/internal/vet/vet_test.go |
| @@ -36,7 +36,7 @@ func shipBinary(t *testing.T, store release.Store, app, version string, bin []by |
| Schema: 1, App: app, Version: version, Kind: "binary", |
| Artifacts: map[string]release.Artifact{key: {File: app}}, |
| } |
| - if err := release.Ship(context.Background(), store, m, map[string][]byte{key: bin}); err != nil { |
| + if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{key: bin}); err != nil { |
| t.Fatalf("Ship %s %s: %v", app, version, err) |
| } |
| } |
| @@ -44,14 +44,14 @@ func shipBinary(t *testing.T, store release.Store, app, version string, bin []by |
| func TestCheckPassesGoodRelease(t *testing.T) { |
| store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| shipBinary(t, store, "echoapp", "v1", []byte("not a real binary, hashes fine")) |
| - if probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", false); len(probs) != 0 { |
| + if probs := Check(context.Background(), store, testPub(t), release.OpsAccount, "echoapp", "v1", false); len(probs) != 0 { |
| t.Fatalf("Check = %v, want no problems", probs) |
| } |
| } |
| func TestCheckReportsMissingManifest(t *testing.T) { |
| store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| - probs := Check(context.Background(), store, testPub(t), "ghost", "v9", false) |
| + probs := Check(context.Background(), store, testPub(t), release.OpsAccount, "ghost", "v9", false) |
| if len(probs) != 1 || !strings.Contains(probs[0], "manifest") { |
| t.Fatalf("Check = %v, want one manifest problem", probs) |
| } |
| @@ -63,12 +63,12 @@ func TestCheckReportsTamperedArtifact(t *testing.T) { |
| shipBinary(t, store, "echoapp", "v1", []byte("original artifact bytes")) |
| // Corrupt the stored bytes underneath the manifest's sha256. |
| - artPath := filepath.Join(root, filepath.FromSlash(release.ArtifactKey("echoapp", "v1", "echoapp"))) |
| + artPath := filepath.Join(root, filepath.FromSlash(release.ArtifactKey(release.OpsAccount, "echoapp", "v1", "echoapp"))) |
| if err := os.WriteFile(artPath, []byte("EVIL artifact bytes!!!!"), 0o644); err != nil { |
| t.Fatalf("corrupt artifact: %v", err) |
| } |
| - probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", false) |
| + probs := Check(context.Background(), store, testPub(t), release.OpsAccount, "echoapp", "v1", false) |
| if len(probs) != 1 || !strings.Contains(probs[0], "sha256 mismatch") { |
| t.Fatalf("Check = %v, want one sha256-mismatch problem", probs) |
| } |
| @@ -103,7 +103,7 @@ func TestCheckBootHealthz(t *testing.T) { |
| bin := buildEchoapp(t) |
| store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| shipBinary(t, store, "echoapp", "v1", bin) |
| - if probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", true); len(probs) != 0 { |
| + if probs := Check(context.Background(), store, testPub(t), release.OpsAccount, "echoapp", "v1", true); len(probs) != 0 { |
| t.Fatalf("Check with boot = %v, want no problems", probs) |
| } |
| } |
| @@ -122,7 +122,7 @@ func TestCheckBootFlagsBinaryThatNeverServes(t *testing.T) { |
| store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| shipBinary(t, store, "deadapp", "v1", falseBin) |
| - probs := Check(context.Background(), store, testPub(t), "deadapp", "v1", true) |
| + probs := Check(context.Background(), store, testPub(t), release.OpsAccount, "deadapp", "v1", true) |
| if len(probs) != 1 || !strings.Contains(probs[0], "boot:") { |
| t.Fatalf("Check = %v, want one boot problem", probs) |
| } |