carlos / platform Public

accounts: a diverged destination pointer converges — the instruction now has an exit

The verify refusal told the operator to "re-run the migration once the writer
has stopped", but the destination pointer existed by then and the copy is
create-only, so the re-run died at "already exists with different content".
The state was safe and un-re-runnable, and the message forbade the only way
out.

The split that fixes it is the one the bucket already makes: release manifests
and artifacts are content-addressed and immutable, so different bytes at an
existing path stays a hard refusal — an immutable path must never change
meaning. A channel pointer is the one MUTABLE object; it moves on every
promote, so a destination pointer that disagrees is this migration's own
leftover and the verb's whole job is to make the destination serve what the
source serves. Pointers now converge via CAS-with-etag (one retry on a lost
race), in the copy AND in verify, and verify re-reads once after converging.
Only a second consecutive mismatch — something actively writing — is fatal,
and it now names the exact key to delete rather than a vague "try again".

Also: the bake clock key was not injective. Channels may contain "/", so ops
app "kass" on channel "canary/x" produced the same key as account "kass", app
"canary", channel "x" — one account's observation satisfying another's window,
the same bug from a different direction. Non-ops keys now join on "|", which
cannot occur in a sqid, an app name or a channel segment; ops keeps the
pre-accounts shape (injective on its own, since app names have no "/") so
every existing box's observations still resolve.

And a one-sentence hedge on the account-mismatch banner: the console row is
usually what is behind, but if the ROUTES are the ones in the wrong account,
move those back — whichever is right, the two have to agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 0b7c3dc1f50aa6cc770f88989151b6ddeeda4c42 parent ff5da8e
5 files changed, +255 −14
  • accounts.go +92 −12
  • e2e_test.go +118 −0
  • internal/console/templates/app.html +3 −0
  • internal/release/bake.go +17 −2
  • internal/release/bake_test.go +25 −0
diff --git a/accounts.go b/accounts.go
index d5e2b02a8..1f9b26ead 100644
--- a/accounts.go
+++ b/accounts.go
@@ -15,6 +15,7 @@ import (
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
+ "errors"
"flag"
"fmt"
"log"
@@ -219,16 +220,37 @@ func runAccountsMigrate(args []string) {
if err != nil {
log.Fatalf("accounts migrate: read %s: %v", c.from, err)
}
- // Create-only, then verify: re-running the migration over identical
- // bytes is a no-op, and DIFFERENT bytes at a destination that already
- // exists is refused loudly rather than overwritten.
+ // Channel pointers CONVERGE; everything else is immutable and is
+ // claimed create-only. The split matters: a release manifest or
+ // artifact at an existing path must never change meaning, so
+ // different bytes there is a hard refusal. A channel pointer is the
+ // one mutable object in the bucket — it moves on every promote — so a
+ // destination pointer that disagrees is this migration's own
+ // leftover, and the verb's whole job is to make the destination serve
+ // what the source serves. Refusing there would strand the operator:
+ // the pointer exists, so a re-run would hit exactly this branch
+ // again, with no way forward that did not involve hand-deleting
+ // objects mid-migration.
+ if c.pointer {
+ switch changed, err := putOverwrite(ctx, store, c.to, body); {
+ case err != nil:
+ log.Fatalf("accounts migrate: write %s: %v", c.to, err)
+ case changed:
+ fmt.Printf("converged %s -> %s (the destination pointer disagreed)\n", c.from, c.to)
+ default:
+ fmt.Printf("already copied %s -> %s\n", c.from, c.to)
+ }
+ continue
+ }
if _, err := store.Put(ctx, c.to, body, ""); err != nil {
cur, _, gerr := store.Get(ctx, c.to)
if gerr != nil {
log.Fatalf("accounts migrate: write %s: %v", c.to, err)
}
if string(cur) != string(body) {
- log.Fatalf("accounts migrate: %s already exists with different content — refusing to overwrite", c.to)
+ log.Fatalf("accounts migrate: %s already exists with different content — refusing to\n"+
+ "overwrite an immutable release object. Nothing has been deleted; work out which\n"+
+ "of the two is the real %s before going further.", c.to, *app)
}
fmt.Printf("already copied %s -> %s\n", c.from, c.to)
continue
@@ -275,8 +297,46 @@ func runAccountsMigrate(args []string) {
fmt.Printf("\nThe console keeps its own app→account row: run this on the console's box to\ncomplete the move:\n\n %s\n", followUp)
}
-// copy is one object the migration moves.
-type copyOp struct{ from, to string }
+// copyOp is one object the migration moves. pointer marks a channel pointer —
+// the only mutable object shape in the bucket, and so the only one the
+// migration is allowed to overwrite.
+type copyOp struct {
+ from, to string
+ pointer bool
+}
+
+// isChannelPointer reports whether a key under an app prefix is a channel
+// pointer rather than an immutable release object.
+func isChannelPointer(rel string) bool { return strings.HasPrefix(rel, "channels/") }
+
+// putOverwrite writes body at key whatever is there now: read the etag, put
+// with it, one retry on a lost race (the CAS-guarded read-then-write the
+// fleet publisher and the console's manifest writer both use). Reports
+// whether the bytes actually changed, so the caller can say so.
+func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) (changed bool, err error) {
+ attempt := func() (bool, error) {
+ etag := ""
+ cur, cetag, gerr := s.Get(ctx, key)
+ switch {
+ case gerr == nil:
+ if string(cur) == string(body) {
+ return false, nil // already right; do not churn the etag
+ }
+ etag = cetag
+ case !errors.Is(gerr, os.ErrNotExist):
+ return false, gerr
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+ changed, err = attempt()
+ if errors.Is(err, release.ErrCASConflict) {
+ return attempt()
+ }
+ return changed, err
+}
// migrationPlan works out which objects to copy, and from where. This is the
// most dangerous decision the verb makes.
@@ -312,7 +372,8 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
}
var out []copyOp
for _, k := range keys {
- out = append(out, copyOp{k, dst + strings.TrimPrefix(k, src)})
+ rel := strings.TrimPrefix(k, src)
+ out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)})
}
return out, from
}
@@ -348,14 +409,14 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
for _, k := range opsKeys {
rel := strings.TrimPrefix(k, opsPrefix)
seen[rel] = true
- out = append(out, copyOp{k, dst + rel})
+ out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)})
}
for _, k := range legacyKeys {
rel := strings.TrimPrefix(k, legacyPrefix)
if seen[rel] {
continue // the qualified copy is the live one
}
- out = append(out, copyOp{k, dst + rel})
+ out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)})
}
return out, release.OpsAccount
}
@@ -395,9 +456,28 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc
log.Fatalf("accounts migrate: verify: re-read %s/%s at the source: %v", app, channel, err)
}
if string(got) != string(want) {
- log.Fatalf("accounts migrate: VERIFICATION FAILED for channel %s: what %s now serves is not\n"+
- "what %s serves. Something wrote to this app during the migration. Do NOT delete\n"+
- "anything; re-run the migration once the writer has stopped.", channel, dstAcct, srcAcct)
+ // A write landed between the copy and this read — a promote
+ // racing the migration. CONVERGE rather than refuse: the pointer
+ // is this migration's own object, and telling an operator to
+ // "re-run" while leaving a destination pointer that the create-only
+ // copy would then reject is an instruction with no exit.
+ log.Printf("accounts migrate: %s/%s moved during the migration — re-copying", app, channel)
+ if _, err := putOverwrite(ctx, store, key, want); err != nil {
+ log.Fatalf("accounts migrate: converge %s: %v", key, err)
+ }
+ got, _, err = store.Get(ctx, key)
+ if err != nil {
+ log.Fatalf("accounts migrate: verify %s: %v", key, err)
+ }
+ if string(got) != string(want) {
+ // Twice in a row means something is actively writing. Name the
+ // exact recovery rather than a vague "try again".
+ log.Fatalf("accounts migrate: VERIFICATION FAILED for channel %s: %s still does not serve\n"+
+ "what %s serves after a re-copy, so something is writing to this app right now.\n"+
+ "Nothing has been deleted. Stop the writer, then either re-run this command or\n"+
+ "delete this one key and re-run:\n\n %s\n",
+ channel, dstAcct, srcAcct, key)
+ }
}
if pub != nil {
p, err := release.Channel(ctx, store, pub, dstAcct, app, channel)
diff --git a/e2e_test.go b/e2e_test.go
index 5179c4cd7..527d91c15 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -937,6 +937,124 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) {
}
}
+// A destination channel pointer that disagrees with the source must CONVERGE,
+// not strand the operator. Channel pointers are the one mutable thing in the
+// bucket — they move on every promote — so a stale or half-written one at the
+// destination is this migration's own leftover, not foreign data, and the
+// verb's job is to make the destination serve what the source serves.
+//
+// The alternative shape (refuse, and tell the operator to re-run) was a dead
+// end: the re-run would hit the create-only copy and die at "already exists
+// with different content", so the instruction forbade the only exit.
+func TestAccountsMigrateConvergesADivergedPointer(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ storeDir := filepath.Join(tmp, "store")
+ regPath := filepath.Join(tmp, "registry.db")
+ store := release.NewDirStore(storeDir)
+ ctx := context.Background()
+
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ env := append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_RELEASE_PUBKEY="+pubB64)
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ for _, ver := range []string{"v1", "v2"} {
+ m := release.Manifest{Schema: 1, App: "demo", Version: ver, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}}
+ if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte(ver + " bytes")}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass")
+ if err != nil {
+ t.Fatalf("accounts create: %v\n%s", err, out)
+ }
+ sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1]
+
+ // A half-finished earlier attempt: the destination already holds this
+ // app's releases AND a pointer at the OLD version. (Copied the way the
+ // verb copies — same bytes, so only the POINTER diverges.)
+ srcKeys, err := store.List(ctx, release.AppPrefix(release.OpsAccount, "demo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range srcKeys {
+ if strings.Contains(k, "/channels/") {
+ continue
+ }
+ body, _, err := store.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dstKey := release.AppPrefix(sqid, "demo") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "demo"))
+ if _, err := store.Put(ctx, dstKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := release.Promote(ctx, store, priv, sqid, "demo", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if p, _ := release.Channel(ctx, store, pub, sqid, "demo", "stable"); p == nil || p.Version != "v1" {
+ t.Fatalf("fixture: destination pointer is %+v, want v1", p)
+ }
+
+ out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid)
+ if err != nil {
+ t.Fatalf("migrate over a diverged destination pointer must converge, not strand:\n%s", out)
+ }
+ got, err := release.Channel(ctx, store, pub, sqid, "demo", "stable")
+ if err != nil {
+ t.Fatalf("resolve the migrated channel: %v", err)
+ }
+ if got.Version != "v2" {
+ t.Fatalf("destination serves %s, want v2 — the migration did not converge the pointer", got.Version)
+ }
+ if !strings.Contains(out, "stable") {
+ t.Errorf("the verb overwrote a pointer without saying so:\n%s", out)
+ }
+ // And it is re-runnable: that was the whole point.
+ if out, err := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); err != nil {
+ t.Fatalf("a second run must be a no-op:\n%s", out)
+ }
+}
+
// TestAddUnitBackingRequiresDBForHibernate pins the I1 fix's operator-facing
// half: `carlos add -backing unit -hibernate` with no -db must refuse,
// because ops.FleetDBs (the box-wide litestream unit) has no way to
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
index ab359aeae..68c50820a 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app.html
@@ -10,6 +10,9 @@
nothing. Its bucket objects were moved without the console's own record
following. On the console's box, run:
<br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
+ <br><span class="small">If it is the routes that are in the wrong account,
+ move those back instead — whichever of the two is right, they have to
+ agree.</span>
</p>
{{end}}
diff --git a/internal/release/bake.go b/internal/release/bake.go
index c604df0cf..9def2aae6 100644
--- a/internal/release/bake.go
+++ b/internal/release/bake.go
@@ -89,14 +89,29 @@ type FirstSeen struct {
// The ops account keeps the pre-accounts key shape ("<app>/<channel>"), so
// every existing box's observations carry across an upgrade. Restarting them
// would freeze the whole fleet for 72h on the day this ships, which is a
-// worse failure than the one it would be protecting against.
+// worse failure than the one it would be protecting against. That shape is
+// injective on its own: an app name can never contain "/", so the first
+// segment is always the app and the rest is always the channel.
+//
+// Everyone else is joined with clockSep, NOT "/" — channels may contain a
+// "/" (canary/<branch>), so "/" between segments would make ops app "kass" on
+// channel "canary/x" collide with account "kass", app "canary", channel "x",
+// and one account's observation would satisfy another's window. clockSep
+// cannot appear in a sqid, an app name or a channel segment, so the split is
+// unambiguous — and because it can never appear in an ops key either, the two
+// shapes cannot collide with each other.
func channelClockKey(acct, app, channel string) string {
if acct == "" || acct == OpsAccount {
return app + "/" + channel
}
- return acct + "/" + app + "/" + channel
+ return acct + clockSep + app + clockSep + channel
}
+// clockSep separates the bake clock's key segments. "|" is outside every
+// character class the platform allows in a sqid (lowercase letters), an app
+// name (appRe) or a channel segment (channelSegRe).
+const clockSep = "|"
+
// LoadFirstSeen reads the bake clock from path. A missing or corrupt file is
// an empty clock — every head then bakes from "now", which fails closed (a
// wiped clock delays updates; it never lets one through early).
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go
index 883521407..1217e5734 100644
--- a/internal/release/bake_test.go
+++ b/internal/release/bake_test.go
@@ -190,6 +190,31 @@ func TestBakeClockIsPerAccount(t *testing.T) {
}
}
+// The clock key must be injective. Channels may contain "/", so joining
+// segments with "/" would make ops app "kass" + channel "canary/x" collide
+// with account "kass" + app "canary" + channel "x" — one account's
+// observation silently satisfying another's window, which is the same bug
+// TestBakeClockIsPerAccount closes, reached by a different route.
+func TestBakeClockKeyIsInjective(t *testing.T) {
+ st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json"))
+ long := time.Now().Add(-100 * time.Hour)
+
+ // ops app "kass" on channel "canary/x".
+ st.Observe(OpsAccount, "kass", "canary/x", "v1", long)
+ // Account "kass", app "canary", channel "x" — three different segments
+ // that concatenate the same way, and must NOT resolve to that record.
+ if _, ok := st.EligibleAt("kass", "canary", "x", "v1", DefaultHolds); ok {
+ t.Fatal("the clock key collides across (account, app, channel) boundaries")
+ }
+ if hold := Hold(st, "kass", "canary", "x", "v1", DefaultHolds, false, time.Now()); hold == "" {
+ t.Fatal("a colliding key let another account skip its bake window")
+ }
+ // The record it was actually written under still resolves.
+ if hold := Hold(st, OpsAccount, "kass", "canary/x", "v1", DefaultHolds, false, time.Now()); hold != "" {
+ t.Fatalf("the real observation stopped resolving: %s", hold)
+ }
+}
+
// A clock written before accounts existed has bare "<app>/<channel>" keys.
// Reading those as the ops account keeps every existing box's observations —
// otherwise the whole fleet's windows restart on upgrade, and nothing