carlos / platform Public

release: adopt resolves per account — same app name, different accounts, different builds

The lesson from the domains build, applied to every reg.List() consumer
rather than only the writers:

- adopt groups by (account, app, channel), so two routes running an app of the
  same name in different accounts each follow their OWN pointer. Grouping on
  (app, channel) would resolve one and install it on both.
- the on-box version store is account-scoped, because two accounts can also
  pick the SAME version string for different bytes — a single
  <opt>/versions/<ver>/<app> would let one account's binary be executed under
  another account's host. The ops account keeps the pre-accounts path exactly,
  so rolling this out does not restage and restart the existing fleet, and
  LiveVersion now reads the version as the first segment under versions/
  rather than the parent dir (which would read a sqid as a version).
- the public status page groups by (account, app) too. Merging would publish
  one tenant's outage under another's name, and let either tenant's
  StatusNamed opt-in decide whether the other appeared at all. The published
  name is still the app name; the account is only ever the grouping key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell c355261c61eecb760326ae108e31c5234add4715 parent 0a378d0
6 files changed, +232 −27
  • internal/release/adopt.go +4 −4
  • internal/release/adopt_test.go +102 −3
  • internal/release/versions.go +39 −10
  • internal/release/versions_test.go +6 −6
  • internal/status/fleet.go +14 −4
  • internal/status/status_test.go +67 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 78ff5b8f1..8e2f71d58 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -191,7 +191,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
fmt.Printf(" %s: %v\n", rt.Host, err)
return
}
- if err := PointLive(optDir, rt.Host, ver, rt.App); err != nil {
+ if err := PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App); err != nil {
fmt.Printf(" %s: repoint failed: %v\n", rt.Host, err)
return
}
@@ -201,7 +201,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// the host keeps running what it ran.
poisoned[ver] = true
if cur != "" {
- _ = PointLive(optDir, rt.Host, cur, rt.App)
+ _ = PointLive(optDir, rt.Host, RouteAccount(rt), cur, rt.App)
}
fmt.Printf(" %s: update to %s failed: %v — rolled the symlink back\n", rt.Host, ver, err)
return
@@ -224,7 +224,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// 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, acct, app, ver string) error {
- if _, err := os.Stat(VersionPath(optDir, ver, app)); err == nil {
+ if _, err := os.Stat(VersionPath(optDir, acct, ver, app)); err == nil {
return nil
}
m, err := GetManifest(ctx, s, acct, app, ver)
@@ -246,5 +246,5 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) e
if got := etagOf(body); got != art.SHA256 {
return fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)
}
- return StageArtifact(optDir, ver, app, body)
+ return StageArtifact(optDir, acct, ver, app, body)
}
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index cbb686b3b..3d81c8f38 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -64,7 +64,7 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) {
t.Fatalf("restarted = %v, want only awake.example.com", restarted)
}
// The staged artifact matches the shipped bytes (sha256-gated download).
- body, err := os.ReadFile(VersionPath(opt, "v1", "myapp"))
+ body, err := os.ReadFile(VersionPath(opt, "bab", "v1", "myapp"))
if err != nil || string(body) != "build one" {
t.Fatalf("staged bytes = %q, %v", body, err)
}
@@ -80,12 +80,12 @@ func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) {
// Both hosts start on v1 (staged locally), channel head moves to v2.
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 {
+ if err := StageArtifact(opt, "bab", "v1", "myapp", []byte("good")); err != nil {
t.Fatal(err)
}
for _, h := range []string{"a.example.com", "b.example.com"} {
addRoute(t, reg, h, "myapp", "canary/x", false)
- if err := PointLive(opt, h, "v1", "myapp"); err != nil {
+ if err := PointLive(opt, h, "bab", "v1", "myapp"); err != nil {
t.Fatal(err)
}
}
@@ -219,6 +219,105 @@ func TestPassSkipsPlatformMintedRoutes(t *testing.T) {
}
}
+// The test that proves per-account app names actually work end to end: two
+// routes, the SAME app name, different accounts, different channel pointers.
+// Each host must adopt its own account's version. Before accounts, adopt
+// grouped by (app, channel) and would have installed one of them on both.
+func TestPassResolvesPerAccount(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "blog", "v1", adoptPlatform, []byte("ops build"))
+ shipRelease(t, s, "kqmv", "blog", "v2", adoptPlatform, []byte("kass build"))
+ if err := Promote(ctx, s, priv, "bab", "blog", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "kqmv", "blog", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "ops.example.com", Kind: "instance", App: "blog", Channel: "canary/x", Account: "bab", Status: "ready"},
+ {Host: "kass.example.com", Kind: "instance", App: "blog", Channel: "canary/x", Account: "kqmv", Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ for host, want := range map[string]string{
+ "ops.example.com": "v1",
+ "kass.example.com": "v2",
+ } {
+ if got, _ := LiveVersion(opt, host); got != want {
+ t.Errorf("%s live = %q, want %q — each account follows its OWN pointer", host, got, want)
+ }
+ }
+}
+
+// Same app name, same VERSION STRING, different accounts, different bytes.
+// The on-box version store must keep them apart, or one account's binary gets
+// executed under another account's host — the same collision in a different
+// layer from the channel-pointer one above.
+func TestPassStagesPerAccountForIdenticalVersions(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "blog", "v1", adoptPlatform, []byte("ops bytes"))
+ shipRelease(t, s, "kqmv", "blog", "v1", adoptPlatform, []byte("kass bytes"))
+ for _, acct := range []string{"bab", "kqmv"} {
+ if err := Promote(ctx, s, priv, acct, "blog", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, rt := range []registry.Route{
+ {Host: "ops.example.com", Kind: "instance", App: "blog", Channel: "canary/x", Account: "bab", Status: "ready"},
+ {Host: "kass.example.com", Kind: "instance", App: "blog", Channel: "canary/x", Account: "kqmv", Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ for host, want := range map[string]string{
+ "ops.example.com": "ops bytes",
+ "kass.example.com": "kass bytes",
+ } {
+ target, err := os.Readlink(LiveLink(opt, host))
+ if err != nil {
+ t.Fatalf("%s: read live link: %v", host, err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatalf("%s: read staged binary: %v", host, err)
+ }
+ if string(got) != want {
+ t.Errorf("%s runs %q, want %q — one account's build was staged over another's", host, got, want)
+ }
+ }
+}
+
+// The ops account keeps the pre-accounts on-disk layout byte for byte, so
+// rolling this code out does not restage and restart every existing host.
+func TestOpsVersionPathIsUnchanged(t *testing.T) {
+ opt := "/opt/carlos"
+ if got := VersionPath(opt, "bab", "v1", "kass"); got != "/opt/carlos/versions/v1/kass" {
+ t.Errorf("ops VersionPath = %q, want the pre-accounts path", got)
+ }
+ if got := VersionPath(opt, "kqmv", "v1", "kass"); got != "/opt/carlos/versions/v1/kqmv/kass" {
+ t.Errorf("non-ops VersionPath = %q", got)
+ }
+}
+
func TestPassMissingPlatformArtifactSkips(t *testing.T) {
s := NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/release/versions.go b/internal/release/versions.go
index 49a872610..9212f2ce4 100644
--- a/internal/release/versions.go
+++ b/internal/release/versions.go
@@ -22,6 +22,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strings"
"time"
)
@@ -37,9 +38,21 @@ func OptDir() string {
func versionsDir(optDir string) string { return filepath.Join(optDir, "versions") }
func liveDir(optDir string) string { return filepath.Join(optDir, "live") }
-// VersionPath is where a staged build of app lives: <opt>/versions/<ver>/<app>.
-func VersionPath(optDir, ver, app string) string {
- return filepath.Join(versionsDir(optDir), ver, app)
+// VersionPath is where a staged build of app lives. App names are unique per
+// ACCOUNT, so two accounts can both have a "blog" — and, since versions are
+// caller-chosen strings, both can have a "v1" whose bytes differ. A single
+// <opt>/versions/<ver>/<app> would let one account's binary be staged over
+// another's and then executed under its host.
+//
+// The ops account deliberately keeps the pre-accounts path byte for byte
+// (<opt>/versions/<ver>/<app>): every existing box's live symlinks point
+// there, and moving them would restage and restart the whole fleet on
+// rollout for no benefit. Everyone else gets a level in between.
+func VersionPath(optDir, acct, ver, app string) string {
+ if acct == "" || acct == OpsAccount {
+ return filepath.Join(versionsDir(optDir), ver, app)
+ }
+ return filepath.Join(versionsDir(optDir), ver, acct, app)
}
// LiveLink is the per-host pointer the unit execs (<opt>/live/<host>) — a
@@ -110,14 +123,17 @@ func WriteFileAtomic(dst string, data []byte, mode os.FileMode) error {
// StageArtifact puts verified artifact bytes into the store under ver, so
// hosts can be pointed at it. Idempotent: re-staging overwrites.
-func StageArtifact(optDir, ver, app string, data []byte) error {
+func StageArtifact(optDir, acct, ver, app string, data []byte) error {
if !ValidVersion(ver) {
return fmt.Errorf("invalid version %q (allowed: letters, digits, . _ -)", ver)
}
if !ValidApp(app) {
return fmt.Errorf("invalid app %q", app)
}
- dir := filepath.Join(versionsDir(optDir), ver)
+ if acct != "" && !ValidAccount(acct) {
+ return fmt.Errorf("invalid account %q", acct)
+ }
+ dir := filepath.Dir(VersionPath(optDir, acct, ver, app))
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -129,14 +145,14 @@ func StageArtifact(optDir, ver, app string, data []byte) error {
if err := os.Chmod(dir, 0o755); err != nil {
return err
}
- return WriteFileAtomic(VersionPath(optDir, ver, app), data, 0o755)
+ return WriteFileAtomic(VersionPath(optDir, acct, ver, app), data, 0o755)
}
// PointLive atomically repoints a host's live symlink at ver's build of app.
// It verifies ver is staged first, so a typo can't leave a dangling link
// that would fail the unit's ExecStart on the next restart.
-func PointLive(optDir, host, ver, app string) error {
- target := VersionPath(optDir, ver, app)
+func PointLive(optDir, host, acct, ver, app string) error {
+ target := VersionPath(optDir, acct, ver, app)
if _, err := os.Stat(target); err != nil {
return fmt.Errorf("version %q of %s is not staged (%s)", ver, app, target)
}
@@ -166,8 +182,21 @@ func LiveVersion(optDir, host string) (string, error) {
}
return "", err
}
- // target = <opt>/versions/<ver>/<app> -> the parent dir's name is <ver>.
- return filepath.Base(filepath.Dir(target)), nil
+ // target is <opt>/versions/<ver>/<app> (ops) or
+ // <opt>/versions/<ver>/<acct>/<app> (everyone else), so the version is the
+ // FIRST segment under the versions dir — not simply the parent directory,
+ // which would read an account sqid as a version.
+ rel, err := filepath.Rel(versionsDir(optDir), target)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ // A link pointing outside the version store isn't ours to interpret;
+ // "" means "not under version management", the same answer as absent.
+ return "", nil
+ }
+ ver, _, ok := strings.Cut(filepath.ToSlash(rel), "/")
+ if !ok {
+ return "", nil
+ }
+ return ver, nil
}
// WaitHealthyVersion waits until the host behind socket not only answers but
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go
index 73f805466..60fc06978 100644
--- a/internal/release/versions_test.go
+++ b/internal/release/versions_test.go
@@ -13,10 +13,10 @@ import (
func TestStagePointLiveRoundTrip(t *testing.T) {
opt := t.TempDir()
- if err := StageArtifact(opt, "v1abc", "myapp", []byte("the binary")); err != nil {
+ if err := StageArtifact(opt, "bab", "v1abc", "myapp", []byte("the binary")); err != nil {
t.Fatalf("StageArtifact: %v", err)
}
- p := VersionPath(opt, "v1abc", "myapp")
+ p := VersionPath(opt, "bab", "v1abc", "myapp")
st, err := os.Stat(p)
if err != nil {
t.Fatalf("staged file: %v", err)
@@ -28,7 +28,7 @@ func TestStagePointLiveRoundTrip(t *testing.T) {
if v, err := LiveVersion(opt, "h.example.com"); err != nil || v != "" {
t.Fatalf("LiveVersion before pointing = %q, %v", v, err)
}
- if err := PointLive(opt, "h.example.com", "v1abc", "myapp"); err != nil {
+ if err := PointLive(opt, "h.example.com", "bab", "v1abc", "myapp"); err != nil {
t.Fatalf("PointLive: %v", err)
}
v, err := LiveVersion(opt, "h.example.com")
@@ -36,7 +36,7 @@ func TestStagePointLiveRoundTrip(t *testing.T) {
t.Fatalf("LiveVersion = %q, %v; want v1abc", v, err)
}
// Repointing at an unstaged version is refused (no dangling links).
- if err := PointLive(opt, "h.example.com", "vGhost", "myapp"); err == nil {
+ if err := PointLive(opt, "h.example.com", "bab", "vGhost", "myapp"); err == nil {
t.Fatalf("PointLive accepted an unstaged version")
}
// The refusal left the link untouched.
@@ -44,10 +44,10 @@ func TestStagePointLiveRoundTrip(t *testing.T) {
t.Fatalf("live link moved to %q after refused repoint", v)
}
// A second staged version swaps atomically.
- if err := StageArtifact(opt, "v2def", "myapp", []byte("newer binary")); err != nil {
+ if err := StageArtifact(opt, "bab", "v2def", "myapp", []byte("newer binary")); err != nil {
t.Fatalf("StageArtifact v2: %v", err)
}
- if err := PointLive(opt, "h.example.com", "v2def", "myapp"); err != nil {
+ if err := PointLive(opt, "h.example.com", "bab", "v2def", "myapp"); err != nil {
t.Fatalf("PointLive v2: %v", err)
}
if v, _ := LiveVersion(opt, "h.example.com"); v != "v2def" {
diff --git a/internal/status/fleet.go b/internal/status/fleet.go
index cf1af6998..e75ad6fca 100644
--- a/internal/status/fleet.go
+++ b/internal/status/fleet.go
@@ -250,20 +250,30 @@ type routeGroup struct {
routes []registry.Route
}
-// groupRoutesByApp buckets routes matching kind into per-App groups,
+// groupRoutesByApp buckets routes matching kind into per-app groups,
// preserving first-seen order so output is deterministic without needing a
// separate sort pass.
+//
+// The group key is (ACCOUNT, app), not app: app names are unique within an
+// account, never globally, so two tenants can both run a "blog". Keying on
+// the name alone would merge them — publishing one tenant's outage under the
+// other's name, and letting either tenant's StatusNamed opt-in decide whether
+// the other appears at all. The published NAME stays the app name; the
+// account is only ever the grouping key, never anything the public page
+// reveals.
func groupRoutesByApp(routes []registry.Route, kind string) []routeGroup {
- index := map[string]int{}
+ type key struct{ acct, app string }
+ index := map[key]int{}
var groups []routeGroup
for _, r := range routes {
if r.Kind != kind {
continue
}
- i, ok := index[r.App]
+ k := key{release.RouteAccount(r), r.App}
+ i, ok := index[k]
if !ok {
i = len(groups)
- index[r.App] = i
+ index[k] = i
groups = append(groups, routeGroup{name: r.App})
}
groups[i].routes = append(groups[i].routes, r)
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index ad2fb4b8a..0c6b11eda 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -268,6 +268,73 @@ func TestFleetAppsOptIn(t *testing.T) {
}
}
+// App names are unique per ACCOUNT, never globally, so two tenants can both
+// run a "blog". Grouping on the name alone would merge them — publishing one
+// tenant's outage under the other's name, and letting either tenant's opt-in
+// decide whether the other appears at all.
+func TestFleetAppsAreGroupedPerAccount(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "blog.one", Kind: "instance", App: "blog", Account: "bab", Status: "ready",
+ Channel: "stable", Version: "v1", StatusNamed: true},
+ {Host: "blog.two", Kind: "instance", App: "blog", Account: "kqmv", Status: "failed",
+ Channel: "stable", Version: "v9", StatusNamed: true},
+ }
+ s := teststore(t)
+ if err := status.WriteBox(context.Background(), s, boxFixture("solo", "plenty", time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(context.Background(), s, routes, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.Apps) != 2 {
+ t.Fatalf("apps = %+v, want two separate blogs (one per account)", f.Apps)
+ }
+ byVersion := map[string]bool{}
+ for _, a := range f.Apps {
+ if a.Name != "blog" {
+ t.Errorf("app name = %q, want the app name (never the sqid)", a.Name)
+ }
+ byVersion[a.Version] = a.Healthy
+ }
+ if !byVersion["v1"] {
+ t.Error("the healthy account's blog was dragged down by the other's")
+ }
+ if byVersion["v9"] {
+ t.Error("the failing account's blog was reported healthy")
+ }
+}
+
+// A tenant that did NOT opt in must stay dark even when another account's
+// same-named app did — the opt-in is per app, and merging would publish an
+// app that never asked to be named.
+func TestFleetOptInIsPerAccount(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "blog.one", Kind: "instance", App: "blog", Account: "bab", Status: "ready",
+ Channel: "stable", Version: "v1", StatusNamed: true},
+ {Host: "blog.two", Kind: "instance", App: "blog", Account: "kqmv", Status: "ready",
+ Channel: "stable", Version: "secret", StatusNamed: false},
+ }
+ s := teststore(t)
+ if err := status.WriteBox(context.Background(), s, boxFixture("solo", "plenty", time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(context.Background(), s, routes, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.Apps) != 1 || f.Apps[0].Version != "v1" {
+ t.Fatalf("apps = %+v, want only the opted-in account's blog", f.Apps)
+ }
+ if f.AppsUnlisted != 1 {
+ t.Errorf("apps_unlisted = %d, want 1", f.AppsUnlisted)
+ }
+ j, _ := json.Marshal(f)
+ if strings.Contains(string(j), "secret") {
+ t.Errorf("un-opted app leaked: %s", j)
+ }
+}
+
func TestFleetWorstOfAndStale(t *testing.T) {
s := teststore(t)
now := time.Now()