deploy: a static app with no route mints its platform host
#186's static half. deploy answers errNoStaticHost by writing the manifest (idempotent upsert, and its response is where the account's pinned domain comes from — the CLI never has to know the deployment's apps domain) and then a kind:static record at <app>.<sqid>.<apps-domain>. Before the ship, on attachUnclaimedHost's precedent: a failed mint must not leave a published version with nothing serving it. The channel guard on the mint is load-bearing, not defensive. deployTargets returns this refusal with an EMPTY channel when the app has no resolvable channel either, and a record written with no channel is defaulted to stable by the door — the unfixable route this whole path exists to avoid. With no channel there is nothing to mint onto, so the refusal stands. A deployment with no apps domain (503) or an operator-pinned instance domain (409) falls back to the -host refusal with the door's own reason attached, classified by STATUS — reading English to make that decision is the mistake errAmbiguousChannel records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+447
−3
apimode.go+26 −1deploy.go+60 −1deploy_e2e_test.go+167 −0deploy_test.go+100 −1internal/console/channelmove_test.go+94 −0
diff --git a/apimode.go b/apimode.go| index a29d61d04..84fedc3cd 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2055,6 +2055,31 @@ type apiInstanceManifest struct { |
| Seed []string `json:"seed"` |
| } |
| +// enableError carries the instances-manifest door's STATUS alongside its |
| +// message. apiErrorFromBody collapses a refusal to its sentence, which is |
| +// right for a message a member reads and wrong for a decision a caller has |
| +// to make: the deploy mint must tell "this deployment has no platform host |
| +// to give" from "your credential just lost authority", and reading English |
| +// to do it is the mistake errAmbiguousChannel's comment records. Same shape |
| +// as domainListError, for the same reason. |
| +type enableError struct { |
| + status int |
| + msg string |
| +} |
| + |
| +func (e *enableError) Error() string { return e.msg } |
| + |
| +// enableUnavailable reports whether the deployment simply has no platform |
| +// host for this app: no apps domain configured at all (503), or an |
| +// instance domain an operator pinned elsewhere in store mode (409), which |
| +// this door refuses to speak for. Both mean "ask the member for a hostname |
| +// instead"; every other status is a real failure and must not be swallowed. |
| +func enableUnavailable(err error) bool { |
| + var ee *enableError |
| + return errors.As(err, &ee) && |
| + (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict) |
| +} |
| + |
| // instancesEnable asks the console to write (or update) the app's instances |
| // manifest under the account's own platform subdomain — the member-facing |
| // half of `carlos instances enable` (issue #66). channel and seed are |
| @@ -2071,7 +2096,7 @@ func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, |
| return apiInstanceManifest{}, err |
| } |
| if status != http.StatusOK { |
| - return apiInstanceManifest{}, apiErrorFromBody(status, body) |
| + return apiInstanceManifest{}, &enableError{status: status, msg: apiErrorFromBody(status, body).Error()} |
| } |
| var out apiInstanceManifest |
| if err := json.Unmarshal(body, &out); err != nil { |
diff --git a/deploy.go b/deploy.go| index 2d5eea14e..6e5a9415d 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -42,7 +42,7 @@ func runDeploy(args []string) { |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest") |
| channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances, or its one channel if it has no instances; error if ambiguous)") |
| - hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host (required for an app with no instances, e.g. a static site)") |
| + hostFlag := fs.String("host", "", "serve on this hostname instead of the app's own platform host (also scopes channel resolution and the watch to it)") |
| timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing") |
| fs.Parse(args) |
| usage := "usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " + |
| @@ -188,6 +188,32 @@ func runDeploy(args []string) { |
| func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) }) |
| } |
| } |
| + // A static app with nothing serving it yet: mint its platform host |
| + // rather than refusing (#186's static half). Typed, never text-matched, |
| + // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint |
| + // never leaves a published version with nothing to serve it. |
| + // |
| + // The channel guard is load-bearing, not defensive: deployTargets |
| + // returns this refusal with an empty channel when the app has no |
| + // resolvable channel EITHER, and minting a record with no channel is |
| + // exactly the stable-defaulted route this path exists to avoid. With no |
| + // channel there is nothing to mint onto, so the refusal stands and names |
| + // -host, which is the step that makes -channel askable next. |
| + if errors.Is(err, errNoStaticHost) && channel != "" { |
| + host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel) |
| + switch { |
| + case mintErr == nil: |
| + hosts, err = []string{host}, nil |
| + case enableUnavailable(mintErr): |
| + // This deployment has no platform host to give — no apps domain, |
| + // or an operator-pinned instance domain. The original refusal is |
| + // the actionable one; the door's own reason rides along so the |
| + // member is not left wondering why nothing was offered. |
| + mutedf(stdout, "%v\n", mintErr) |
| + default: |
| + log.Fatalf("deploy: mint the platform host: %v", mintErr) |
| + } |
| + } |
| if err != nil { |
| log.Fatalf("deploy: %v", err) |
| } |
| @@ -726,6 +752,39 @@ var errAmbiguousChannel = errors.New("ambiguous channel") |
| // mentioned one. |
| var errNoStaticHost = errors.New("no static host") |
| +// mintPlatformHost gives an app its platform hostname — |
| +// <app>.<sqid>.<apps-domain> — and the static route record that serves it. |
| +// This is the whole of what #186's static half was missing: DNS already |
| +// resolves every name under the apps domain to the edge, and the edge |
| +// derives its own per-account wildcard cert from the live route table, so a |
| +// record is the only thing standing between a fresh static app and a URL. |
| +// |
| +// Two existing doors, in order, and no new authority anywhere. The manifest |
| +// PUT is an idempotent upsert — it CREATES when absent and otherwise |
| +// preserves every field, since nil channel and nil seed both mean "leave it" |
| +// — and it answers with the domain the console pins to the account, so this |
| +// client never has to know the deployment's apps domain to build the host. |
| +// |
| +// Then the record, carrying the kind and — the rail that must not bend — an |
| +// EXPLICIT channel. An omitted one is defaulted by the door to the |
| +// manifest's, then to "stable", while a fresh app's pipeline entry is |
| +// "edge": that record would follow a channel nothing ever promotes to, and |
| +// no door can change a record's channel afterwards. Repairing exactly that |
| +// took a raw bucket write (hello.bdn, 2026-08-22). |
| +func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) { |
| + m, err := sess.instancesEnable(ctx, acct, app, nil, nil) |
| + if err != nil { |
| + return "", err |
| + } |
| + rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", nil, nil) |
| + if err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(stdout, "%s %s (static, channel %s)\n", |
| + colorFor(stdout).ok("minted"), rec.Host, rec.Channel) |
| + return rec.Host, nil |
| +} |
| + |
| // attachUnclaimedHost auto-attaches -host before shipping, when this deploy |
| // is targeting a fresh static host with no route of its own yet (self-serve |
| // custom domains, #82/#151): the app's first deploy to a brand-new hostname |
diff --git a/deploy_e2e_test.go b/deploy_e2e_test.go| index a810f457e..76f9e5357 100644 |
| --- a/deploy_e2e_test.go |
| +++ b/deploy_e2e_test.go |
| @@ -445,3 +445,170 @@ func TestE2EDeployThroughConsole(t *testing.T) { |
| // Nothing here may have opened a local registry: this is API mode. |
| checkNoRegistry() |
| } |
| + |
| +// TestE2EStaticDeployMintsPlatformHost is #186's static half, end to end: an |
| +// app with no route and no -host gets one. The console, the edge and the |
| +// bucket are all real, so what this proves is not "the CLI sent a request" |
| +// but that the record the mint writes is the record the edge serves from. |
| +// |
| +// The member never names a hostname. That is the whole feature: DNS under |
| +// the apps domain already resolves to the edge and the edge already derives |
| +// its own wildcard cert, so a record was the only missing piece. |
| +func TestE2EStaticDeployMintsPlatformHost(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + const ( |
| + addr = "paul@keymail.dev" |
| + app = "freshly" |
| + channel = "canary/site" |
| + // What the mint must derive on its own: the app's label under the |
| + // account's platform subdomain, ops being "bab". |
| + wantHost = app + ".bab.oncarlos.test" |
| + ) |
| + |
| + tmp := t.TempDir() |
| + relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| + 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) |
| + } |
| + cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { cst.Close() }) |
| + |
| + var srv *console.Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + srv, err = console.NewServer(console.Config{ |
| + Origin: ts.URL, |
| + Keymail: "http://keymail.invalid", |
| + Registry: filepath.Join(tmp, "registry.db"), |
| + Releases: relStore, |
| + ReleasePub: pub, |
| + Key: priv, |
| + AppScheme: "http", |
| + AppsDomain: "oncarlos.test", |
| + }, cst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { edgeReg.Close() }) |
| + ed, err := edge.New(edgeReg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache")) |
| + ed.SetStaticRecheck(0) |
| + edgeSrv := httptest.NewServer(ed) |
| + t.Cleanup(edgeSrv.Close) |
| + // Standing in for the reconcile pass that materializes the record on a |
| + // box: the same route, on the loopback host the watch dials. |
| + if err := edgeReg.Upsert(registry.Route{ |
| + Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ed.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { |
| + if h != wantHost { |
| + t.Errorf("watch dialled %q, want the MINTED host %q", h, wantHost) |
| + } |
| + return edgeSrv.URL |
| + } |
| + t.Cleanup(func() { watchOrigin = origOrigin }) |
| + |
| + siteDir := filepath.Join(tmp, "site") |
| + if err := os.MkdirAll(siteDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-timeout", "30s", siteDir}) |
| + out := buf.String() |
| + for _, wantLine := range []string{ |
| + "minted " + wantHost, |
| + "deploying https://" + wantHost, |
| + "promoted " + app + " sha0001 -> " + channel, |
| + "live https://" + wantHost, |
| + } { |
| + if !strings.Contains(out, wantLine) { |
| + t.Fatalf("mint deploy stdout missing %q:\n%s", wantLine, out) |
| + } |
| + } |
| + |
| + // The record is the deliverable: a box's reconcile pass mints the route |
| + // from THIS, so the kind and the channel are what decide whether the URL |
| + // ever serves anything. |
| + rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, wantHost) |
| + if err != nil { |
| + t.Fatalf("record after mint: %v", err) |
| + } |
| + if rec.Kind != "static" { |
| + t.Fatalf("record Kind = %q, want static", rec.Kind) |
| + } |
| + if rec.Channel != channel { |
| + t.Fatalf("record Channel = %q, want %q — an omitted channel would have written stable", rec.Channel, channel) |
| + } |
| + assertServes(t, edgeSrv.URL, "hello", "sha0001") |
| + |
| + // A REDEPLOY must not mint again: the record is now in the instance |
| + // list, so the host resolves from it. |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello2"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + buf.Reset() |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| + "-timeout", "30s", siteDir}) |
| + out = buf.String() |
| + if strings.Contains(out, "minted") { |
| + t.Fatalf("redeploy minted a second time:\n%s", out) |
| + } |
| + if !strings.Contains(out, "live https://"+wantHost) { |
| + t.Fatalf("redeploy did not resolve the host from the record:\n%s", out) |
| + } |
| + assertServes(t, edgeSrv.URL, "hello2", "sha0002") |
| + |
| + checkNoRegistry() |
| +} |
diff --git a/deploy_test.go b/deploy_test.go| index 27e38ba46..70ae92f03 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -1590,12 +1590,18 @@ func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) { |
| // Both unresolved: the host refusal names something the member can supply, |
| // so it is the one that surfaces. |
| func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) { |
| - _, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) { |
| + channel, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) { |
| return apiChannelsResult{}, nil |
| }) |
| if !errors.Is(err, errNoStaticHost) { |
| t.Fatalf("err = %v, want errNoStaticHost", err) |
| } |
| + // And the channel is EMPTY here, which is the whole reason runDeploy |
| + // guards its mint on one: a record written with no channel is defaulted |
| + // to stable by the door, and nothing can change it afterwards. |
| + if channel != "" { |
| + t.Fatalf("channel = %q, want empty — runDeploy must have nothing to mint onto", channel) |
| + } |
| } |
| // Unchanged: a -host whose static record names a channel resolves from the |
| @@ -1610,3 +1616,96 @@ func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) { |
| t.Fatalf("got %q %v %v %v", channel, hosts, static, err) |
| } |
| } |
| + |
| +// TestMintPlatformHostSendsKindAndChannel pins the two fields the mint |
| +// cannot get wrong: the record's kind, and an EXPLICIT channel (an omitted |
| +// one is defaulted to stable by the door, and no door can change a record's |
| +// channel afterwards). |
| +func TestMintPlatformHostSendsKindAndChannel(t *testing.T) { |
| + var create map[string]any |
| + var enabled bool |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case strings.HasSuffix(r.URL.Path, "/instances-manifest"): |
| + enabled = true |
| + io.WriteString(w, `{"app":"mysite","domain":"bab.oncarlos.test"}`) |
| + case strings.HasSuffix(r.URL.Path, "/instances"): |
| + if err := json.NewDecoder(r.Body).Decode(&create); err != nil { |
| + t.Errorf("decode create: %v", err) |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`) |
| + default: |
| + t.Errorf("unexpected path %s", r.URL.Path) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + out := captureStdout(t) |
| + host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") |
| + if err != nil { |
| + t.Fatalf("mintPlatformHost: %v", err) |
| + } |
| + if !enabled { |
| + t.Fatal("the manifest was never written; the create door refuses without one") |
| + } |
| + if host != "mysite.bab.oncarlos.test" { |
| + t.Fatalf("host = %q, want the app's label under the manifest's domain", host) |
| + } |
| + if create["kind"] != "static" { |
| + t.Fatalf("create kind = %#v, want static", create["kind"]) |
| + } |
| + if create["channel"] != "edge" { |
| + t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"]) |
| + } |
| + if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) { |
| + t.Fatalf("stdout = %q, want it to name what was minted", got) |
| + } |
| +} |
| + |
| +// A deployment with no platform namespace (503) and one whose instance |
| +// domain an operator pinned elsewhere (409) both mean the same thing: this |
| +// deployment will not mint a platform host for this app. Classified by |
| +// STATUS, never by reading the message. |
| +func TestMintPlatformHostUnavailableIsClassifiedByStatus(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + status int |
| + body string |
| + }{ |
| + {"no apps domain", http.StatusServiceUnavailable, |
| + `{"error":"this console has no platform apps domain configured"}`}, |
| + {"operator-pinned domain", http.StatusConflict, |
| + `{"error":"instances for mysite are managed under go.tito.io"}`}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(tc.status) |
| + io.WriteString(w, tc.body) |
| + })) |
| + defer ts.Close() |
| + _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") |
| + if err == nil { |
| + t.Fatal("want a refusal") |
| + } |
| + if !enableUnavailable(err) { |
| + t.Fatalf("err = %v, want it classified unavailable", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Every other failure is a real one and must NOT be classified as "this |
| +// deployment has no platform host" — a 403 means the credential lost |
| +// authority, which is worth stopping the deploy for. |
| +func TestMintPlatformHostForbiddenIsNotUnavailable(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + io.WriteString(w, `{"error":"not a member"}`) |
| + })) |
| + defer ts.Close() |
| + _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") |
| + if err == nil || enableUnavailable(err) { |
| + t.Fatalf("err = %v, want an unclassified failure", err) |
| + } |
| +} |
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go| new file mode 100644 |
| index 000000000..fccdcc7eb |
| --- /dev/null |
| +++ b/internal/console/channelmove_test.go |
| @@ -0,0 +1,94 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func movePipe(chans ...release.PipelineChannel) *release.Pipeline { |
| + return &release.Pipeline{Schema: 1, Channels: chans} |
| +} |
| + |
| +// The bypass the union exists to close, and the reason guarding only the |
| +// DESTINATION is not enough: the guarded channel is the one being LEFT, and |
| +// the destination carries no rules at all. |
| +func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) { |
| + p := movePipe( |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}, |
| + ) |
| + passkey, approvals := moveGate(p, "production", "edge") |
| + if approvals != 2 { |
| + t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals) |
| + } |
| + if passkey { |
| + t.Fatalf("passkey = true, want false — neither channel asks for one") |
| + } |
| +} |
| + |
| +// And the other half: guarding only the source would let a route be walked |
| +// ONTO a channel whose policy the mover never met. |
| +func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) { |
| + p := movePipe( |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}, |
| + ) |
| + passkey, approvals := moveGate(p, "edge", "production") |
| + if !passkey || approvals != 0 { |
| + t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals) |
| + } |
| +} |
| + |
| +// Each rule at its strictest, across both ends and both approval fields. |
| +func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) { |
| + p := movePipe( |
| + release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}}, |
| + release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}}, |
| + ) |
| + if _, approvals := moveGate(p, "a", "b"); approvals != 3 { |
| + t.Fatalf("approvals = %d, want 3", approvals) |
| + } |
| + if _, approvals := moveGate(p, "b", "a"); approvals != 3 { |
| + t.Fatalf("approvals = %d (reversed), want 3 — the gate is symmetric", approvals) |
| + } |
| +} |
| + |
| +// A legacy app has no pipeline: the stable RUNG keeps the console's standing |
| +// unconditional passkey, in BOTH directions, and nothing else is gated. |
| +func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) { |
| + if passkey, _ := moveGate(nil, "edge", "stable"); !passkey { |
| + t.Fatal("moving ONTO stable must need a passkey") |
| + } |
| + if passkey, _ := moveGate(nil, "stable", "edge"); !passkey { |
| + t.Fatal("moving OFF stable must need a passkey") |
| + } |
| + if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 { |
| + t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals) |
| + } |
| +} |
| + |
| +// canary/* is reserved platform namespace, outside every pipeline: always |
| +// allowed, never gated, even when the app's real channels are strict. |
| +func TestMoveGateCanaryIsUngated(t *testing.T) { |
| + p := movePipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2}}) |
| + if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 { |
| + t.Fatalf("canary→canary must be ungated, got %v/%d", passkey, approvals) |
| + } |
| + // But a canary END does not launder the OTHER end's rules. |
| + if passkey, approvals := moveGate(p, "canary/x", "edge"); !passkey || approvals != 2 { |
| + t.Fatalf("canary→edge must still meet edge's rules, got %v/%d", passkey, approvals) |
| + } |
| +} |
| + |
| +// A pipelined app whose channel names sit outside the legacy vocabulary must |
| +// not pick up legacy rungs by accident: "production" is not "stable". |
| +func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) { |
| + p := movePipe( |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "stable"}, // named like the rung, no rules declared |
| + ) |
| + if passkey, approvals := moveGate(p, "edge", "stable"); passkey || approvals != 0 { |
| + t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals) |
| + } |
| +} |