carlos / platform Public

e2e: a legacy-only app is invisible, and migrate still moves it

The two halves of the retirement, end to end. TestMultiAccountEndToEnd's
unmigrated app now serves nothing at either its custom host or its minted
apex, where it used to serve through the fallback; its fixture builds
genuine signed legacy objects in a scratch store, since Promote no longer
reaches unqualified keys.

TestAccountsMigrateLegacyIntoOps gains the assertion its old comment said
was impossible: before the move, release.Channel refuses the app AND names
`carlos accounts migrate -app oldapp -from legacy -to bab`. That is the
state a deployment lands in by upgrading from a pre-accounts binary, and the
reason the verb has to keep working forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6c2faff70995cdf1593760157687f2dacc0b7b40 parent e74d25a
1 file changed, +47 −20
  • e2e_test.go +47 −20
diff --git a/e2e_test.go b/e2e_test.go
index 28ca3b04b..90b280edc 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -18,7 +18,6 @@ package main_test
import (
"context"
"crypto/ed25519"
- "crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -565,24 +564,35 @@ func TestMultiAccountEndToEnd(t *testing.T) {
t.Fatalf("Promote blog for %s: %v", name, err)
}
}
- // And a legacy app: objects at the account-less keys, exactly as an
- // unmigrated deployment's bucket has them.
- legacyTar := e2eSiteTar(t, "legacy site")
- legacyManifest := release.Manifest{Schema: 1, App: "oldsite", Version: "v1", Kind: "static",
- Artifacts: map[string]release.Artifact{"site.tar.gz": {
- File: "site.tar.gz", SHA256: fmt.Sprintf("%x", sha256.Sum256(legacyTar)), Size: int64(len(legacyTar))}}}
- raw, err := json.MarshalIndent(legacyManifest, "", " ")
- if err != nil {
+ // And a legacy app: manifest, artifact and signed pointer at the
+ // account-less keys, exactly as an unmigrated deployment's bucket has
+ // them. Built in a scratch store and copied down, because a pointer's
+ // signature covers app/channel/version rather than the key path — so
+ // these are genuine legacy objects, signed by the same key a real
+ // deployment used.
+ scratch := release.NewDirStore(filepath.Join(tmp, "scratch"))
+ oldm := release.Manifest{Schema: 1, App: "oldsite", Version: "v1", Kind: "static",
+ Artifacts: map[string]release.Artifact{"site.tar.gz": {}}}
+ if err := release.Ship(ctx, scratch, release.OpsAccount, oldm,
+ map[string][]byte{"site.tar.gz": e2eSiteTar(t, "legacy site")}); err != nil {
t.Fatal(err)
}
- if _, err := store.Put(ctx, release.LegacyArtifactKey("oldsite", "v1", "site.tar.gz"), legacyTar, ""); err != nil {
+ if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "oldsite", "stable", "v1"); err != nil {
t.Fatal(err)
}
- if _, err := store.Put(ctx, release.LegacyManifestKey("oldsite", "v1"), raw, ""); err != nil {
+ staged, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "oldsite"))
+ if err != nil {
t.Fatal(err)
}
- if err := release.Promote(ctx, store, priv, release.OpsAccount, "oldsite", "stable", "v1"); err != nil {
- t.Fatal(err)
+ for _, k := range staged {
+ body, _, err := scratch.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacy := release.LegacyAppPrefix("oldsite") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "oldsite"))
+ if _, err := store.Put(ctx, legacy, body, ""); err != nil {
+ t.Fatal(err)
+ }
}
for _, rt := range []registry.Route{
@@ -610,12 +620,19 @@ func TestMultiAccountEndToEnd(t *testing.T) {
for host, want := range map[string]string{
"blog.kass.test": "kass blog",
"blog.woodstar.test": "woodstar blog",
- "old.test": "legacy site", // served through the read-fallback
} {
if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want {
t.Errorf("GET %s / = %d %q, want 200 %q", host, code, body, want)
}
}
+ // The legacy app serves NOTHING. Its objects are all still at the
+ // account-less keys, and since the read-fallback was retired no read path
+ // resolves through them — the edge cannot find a pointer, so the route is
+ // a route to nowhere until somebody runs `accounts migrate -from legacy`.
+ // Serving it would be the stale-shadow risk the retirement removed.
+ if code, body := e2eGet(t, srv.URL, "old.test", "/"); code == 200 {
+ t.Errorf("GET old.test / = %d %q — a legacy-only app is still being served", code, body)
+ }
// One reconcile pass: accounts materialize with the bucket's ids, and each
// app mints its platform domain under the account its KEY PATHS say it
@@ -635,13 +652,19 @@ func TestMultiAccountEndToEnd(t *testing.T) {
for host, want := range map[string]string{
"blog." + accounts["Kass"] + ".oncarlos.test": "kass blog",
"blog." + accounts["Woodstar"] + ".oncarlos.test": "woodstar blog",
- "oldsite.bab.oncarlos.test": "legacy site",
} {
if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want {
t.Errorf("GET %s / = %d %q, want 200 %q — the platform domain must serve its own account's build",
host, code, body, want)
}
}
+ // oldsite's apex still mints: minting follows the operator's ROUTE, which
+ // is a real record of intent, not the bucket objects. What it must not do
+ // is serve — the account it mints under is where the app's objects OUGHT
+ // to be, and the empty page is the honest report of them not being there.
+ if code, body := e2eGet(t, srv.URL, "oldsite.bab.oncarlos.test", "/"); code == 200 {
+ t.Errorf("GET oldsite.bab.oncarlos.test / = %d %q — a legacy-only app is still being served", code, body)
+ }
}
// TestAccountsCLI drives the `carlos accounts` verbs as an operator would:
@@ -1251,15 +1274,19 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) {
t.Fatal(err)
}
}
- // Sanity, and the reason this test asserts on KEYS rather than on what
- // resolves: ops already "serves" oldapp through the read-fallback, so
- // release.Channel would answer v1 whether or not anything migrated. The
- // migration's job is to put the objects at the qualified keys so the
- // fallback can eventually be retired — that is the thing to assert.
opsKey := release.ChannelKey(release.OpsAccount, "oldapp", "stable")
if _, _, err := store.Get(ctx, opsKey); err == nil {
t.Fatal("fixture: the qualified key already exists — the migration would prove nothing")
}
+ // Before the move, oldapp is INVISIBLE: the read-fallback is retired, so
+ // nothing resolves it and the miss names the exact command that fixes it.
+ // This is the state a deployment lands in by upgrading from a
+ // pre-accounts binary — the reason this verb has to keep working forever.
+ if _, err := release.Channel(ctx, store, pub, release.OpsAccount, "oldapp", "stable"); err == nil {
+ t.Fatal("a legacy-only app resolved — the read-fallback is supposed to be retired")
+ } else if !strings.Contains(err.Error(), "carlos accounts migrate -app oldapp -from legacy -to bab") {
+ t.Errorf("the miss does not name the fix:\n%v", err)
+ }
// A bare move into ops is still refused — the ambiguity guard stays — and
// the refusal now NAMES the way out.