static sites: name the shape mismatch instead of denying the promote (#380)
A static site shipped, promoted, and then given an INSTANCE record — the kind `carlos instances create` defaults to — produces a route that can never wake. A static release's manifest keys its artifact "site" and carries no build for any box's platform, so ensureStaged skips it, no live symlink is ever written, and MaterializeHost falls back to its generic absent-link reason: "nothing is promoted for this instance's channel". Which is false, and provably so from the member's own terminal: `carlos channels` shows the promote sitting right there. Two contradictory true- sounding statements, no route from either to the real problem, and an agent session that ended with a filed issue rather than a serving site (carlosframework/skills#29). Three changes, in the order a member meets them: - internal/release: on the failure path only, MaterializeHost now reads the pointer and its manifest to say WHICH absent-link cause this is. A static head under a Kind=="instance" route is named as such, with both commands out of it. Everything else keeps the generic sentence, which is right for every cause it was ever right about. - internal/console: the create door refuses the record instead of storing one that can only fail. Only the exec shapes are gated — a "service" record proxies to upstreams and a "static" one reads the pointer, so neither has anything to mismatch — and every failed read degrades to allowing the create, because this is a guard against a shape mistake, not a rail against a hostile caller. An app with nothing promoted yet is untouched, which is the first-instance case. - cli: `carlos promote <version>` may now omit the channel, resolving it when the app has exactly one and naming the app's real channels when it has several. The ladder in --help was the LEGACY frozen one, and an agent reading it promoted onto "beta" — a name that app did not have. main.go and docs/site/cli.md now say channels are the app's own. Verified on the reported app: techarena.bak.platform.tito.io serves 0.1.0 over 200 with X-Carlos-Version after `instances delete` + `instances create --kind static`. Claude-Session: https://claude.ai/code/session_01VhZ3ynUNoubqfku1HosMyK
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+460
−12
cli.go+75 −3cli_test.go+64 −0docs/site/cli.md+19 −6internal/activator/logsink.go+8 −0internal/activator/logsink_test.go+16 −0internal/console/apiinstances.go+48 −0internal/console/apiinstances_test.go+97 −0internal/release/materialize.go+54 −1internal/release/materialize_test.go+76 −0main.go+3 −2
CI passed — run details
diff --git a/cli.go b/cli.go| index 0b847357d..0e89a73ae 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2065,16 +2065,22 @@ func runPromote(args []string) { |
| consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| - usage := "usage: carlos promote --app <a> [--hotfix] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <version> <channel>" |
| - if fs.NArg() != 2 { |
| + usage := "usage: carlos promote --app <a> [--hotfix] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <version> [channel]" |
| + if fs.NArg() != 1 && fs.NArg() != 2 { |
| log.Fatal(usage) |
| } |
| - version, channel := fs.Arg(0), fs.Arg(1) |
| + version, channel := fs.Arg(0), fs.Arg(1) // Arg(1) is "" for the one-arg form |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| log.Fatal(err) |
| } |
| + if channel == "" && sess == nil { |
| + // Direct mode is the operator's, against a bucket rather than a |
| + // console: there is no channels door to ask, and an operator |
| + // promoting by hand is the one caller who always knows the name. |
| + log.Fatal(usage) |
| + } |
| if sess != nil { |
| // --hotfix now travels over the wire (release pipelines v2, Task 10): |
| // handleAPIPromote reads it strictly after its own rules decision |
| @@ -2084,6 +2090,13 @@ func runPromote(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, usage) |
| + if channel == "" { |
| + resolved, cerr := promoteChannel(context.Background(), sess, acct, app2) |
| + if cerr != nil { |
| + log.Fatal(cerr) |
| + } |
| + channel = resolved |
| + } |
| res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix) |
| if err != nil { |
| var prop *apiProposedError |
| @@ -2138,6 +2151,65 @@ func runPromote(args []string) { |
| } |
| } |
| +// promoteChannel resolves the channel a one-arg `carlos promote` lands on, |
| +// from the app's own channels rather than from the ladder in --help. |
| +// |
| +// The ladder printed by --help (canary → edge → beta → stable) is the LEGACY |
| +// frozen one, and a pipelined app's channels are its own names, in its own |
| +// order. An agent reading that help and promoting onto "beta" is reading a |
| +// document about a different app — which is exactly what happened |
| +// (skills#29): a two-channel app, a confident guess at a third name, and a |
| +// refusal that (to its credit) named the real one. |
| +// |
| +// So the rule is the same one staticDeployChannel uses: resolve when the |
| +// answer is unambiguous, and when it is not, say what this app actually has |
| +// and let a human pick. A pipeline's declared list wins over the promoted |
| +// rows — a channel with no pointer yet is still a channel you may promote |
| +// onto, and it is the rows that are the incomplete picture, not the pipeline. |
| +// |
| +// canary/* is excluded from the count, not from the choice: it is a reserved |
| +// per-session namespace outside any pipeline (a dead end by design, always |
| +// allowed, zero bake), so a canary someone cut this morning must not turn |
| +// tomorrow's ordinary promote into an ambiguity. Naming one explicitly works |
| +// exactly as it always did. |
| +func promoteChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, error) { |
| + res, err := sess.channels(ctx, acct, app) |
| + if err != nil { |
| + return "", fmt.Errorf("promote: list channels: %w (name the channel to promote onto)", err) |
| + } |
| + return pickPromoteChannel(res, app) |
| +} |
| + |
| +// pickPromoteChannel is promoteChannel's decision, split from its round trip |
| +// so the rules above can be read — and tested — without a console. Same shape |
| +// as staticDeployChannel, for the same reason. |
| +func pickPromoteChannel(res apiChannelsResult, app string) (string, error) { |
| + if len(res.PipelineChannels) == 1 { |
| + return res.PipelineChannels[0], nil |
| + } |
| + if len(res.PipelineChannels) > 1 { |
| + return "", fmt.Errorf("promote: %s releases through %s — name which one to promote onto "+ |
| + "(its entry channel is %s; see carlos pipeline)", |
| + app, strings.Join(res.PipelineChannels, " → "), res.PipelineEntry) |
| + } |
| + var named []string |
| + for _, ch := range res.Channels { |
| + if rung, ok := release.Rung(ch.Channel); ok && rung == release.RungCanary { |
| + continue |
| + } |
| + named = append(named, ch.Channel) |
| + } |
| + switch len(named) { |
| + case 1: |
| + return named[0], nil |
| + case 0: |
| + return "", fmt.Errorf("promote: %s has no channels yet — name the channel to promote onto", app) |
| + default: |
| + return "", fmt.Errorf("promote: %s has %d channels (%s) — name which one to promote onto", |
| + app, len(named), strings.Join(named, ", ")) |
| + } |
| +} |
| + |
| // promoteActor names who is promoting, for the pointer's audit trail: the |
| // logged-in console identity when this terminal has one, and otherwise the |
| // operator's user@host. Never fatal and never blocking — a release verb runs |
diff --git a/cli_test.go b/cli_test.go| index 75d40da9f..bbf55cb54 100644 |
| --- a/cli_test.go |
| +++ b/cli_test.go |
| @@ -1064,3 +1064,67 @@ func TestNotesFileAndNotesConflict(t *testing.T) { |
| t.Fatalf("refusal does not name --notes-file:\n%s", out) |
| } |
| } |
| + |
| +// pickPromoteChannel is what makes `carlos promote <version>` legal without a |
| +// channel. The rule it has to hold is not "guess well" but "resolve when the |
| +// answer is unambiguous, and otherwise say what this app actually has" — |
| +// skills#29 went wrong at the second half: --help documented a four-rung |
| +// ladder, the app had two channels of its own, and the guess landed on a name |
| +// the app did not have. |
| +func TestPickPromoteChannel(t *testing.T) { |
| + // One channel is the shape of most apps: a new app is born with exactly |
| + // one, and that one is its production. |
| + if ch, err := pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{{Channel: "edge"}}}, "a"); err != nil || ch != "edge" { |
| + t.Fatalf("one channel: %q %v", ch, err) |
| + } |
| + |
| + // The reported app: a canary someone cut this morning beside the one real |
| + // channel. canary/* is a reserved per-session dead end outside any |
| + // pipeline, so it must not turn an ordinary promote into an ambiguity. |
| + got, err := pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{ |
| + {Channel: "canary"}, {Channel: "edge"}, |
| + }}, "techarena") |
| + if err != nil || got != "edge" { |
| + t.Fatalf("canary beside edge: %q %v — a canary must not make the real channel ambiguous", got, err) |
| + } |
| + |
| + // A pipeline's DECLARED list wins over the promoted rows: a channel with |
| + // no pointer yet is still one you may promote onto. |
| + if ch, err := pickPromoteChannel(apiChannelsResult{ |
| + PipelineChannels: []string{"review"}, PipelineEntry: "review", |
| + Channels: []apiChannel{{Channel: "edge"}}, |
| + }, "a"); err != nil || ch != "review" { |
| + t.Fatalf("one-channel pipeline: %q %v", ch, err) |
| + } |
| + |
| + // Several: refuse, and NAME them. This is the half the reporter needed |
| + // and the reason a bare default would be the wrong fix — a promote that |
| + // silently picked one of these could ship to production. |
| + _, err = pickPromoteChannel(apiChannelsResult{ |
| + PipelineChannels: []string{"edge", "beta", "stable"}, PipelineEntry: "edge", |
| + }, "a") |
| + if err == nil { |
| + t.Fatal("a multi-channel pipeline must refuse, not pick") |
| + } |
| + for _, want := range []string{"edge", "beta", "stable", "entry channel is edge"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("refusal %q does not name %q", err, want) |
| + } |
| + } |
| + |
| + _, err = pickPromoteChannel(apiChannelsResult{Channels: []apiChannel{ |
| + {Channel: "edge"}, {Channel: "production"}, |
| + }}, "a") |
| + if err == nil { |
| + t.Fatal("two channels must refuse, not pick") |
| + } |
| + if !strings.Contains(err.Error(), "edge, production") { |
| + t.Errorf("refusal %q does not list the app's own channels", err) |
| + } |
| + |
| + // Nothing to resolve from is its own sentence, not a confusing "0 channels". |
| + if _, err := pickPromoteChannel(apiChannelsResult{}, "a"); err == nil || |
| + !strings.Contains(err.Error(), "no channels yet") { |
| + t.Fatalf("no channels: %v", err) |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 26f3a0bde..9de517f37 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -103,16 +103,29 @@ so write it for the person doing a rollback at 2am. |
| ### carlos promote |
| -Points a channel at a version you have already shipped. The ladder is |
| -`canary → edge → beta → stable`: beta is entered from edge and stable from |
| -beta, so "it was signed off on beta" means something, and reaching stable |
| -cuts the release's semver tag. Channels named `canary/<something>` are never |
| -refused, because that is what the fleet itself runs on. |
| +Points a channel at a version you have already shipped. |
| + |
| +**Channels belong to the app, not to the platform.** A new app is born with |
| +one channel — `edge` by default — and that one channel is its production. |
| +More channels are opt-in, declared by a pipeline, and named whatever the app |
| +calls them. So there is no universal ladder to memorise: `carlos channels` |
| +lists what this app has, and `carlos pipeline` shows the order promotion must |
| +follow through them. |
| + |
| +Leave the channel off and an app with one channel promotes onto it. An app |
| +with several says so, and names them, rather than guessing: |
| ```sh |
| -carlos promote --app hello a1b2c3d edge |
| +carlos promote --app hello a1b2c3d # the app's one channel |
| +carlos promote --app hello a1b2c3d edge # or name it |
| ``` |
| +An app that declares no pipeline keeps the frozen legacy ladder, |
| +`canary → edge → beta → stable` — beta entered from edge and stable from |
| +beta, so "it was signed off on beta" means something, and reaching stable |
| +cuts the release's semver tag. Channels named `canary/<something>` are never |
| +refused, because that is what the fleet itself runs on. |
| + |
| Promoting the same version twice succeeds and does nothing. `--hotfix` skips |
| the ladder and is an operator act: it works on the direct-bucket path only, |
| and the console refuses it by name, so the flag never quietly does nothing. |
diff --git a/internal/activator/logsink.go b/internal/activator/logsink.go| index 7f8f74051..6cfd20f78 100644 |
| --- a/internal/activator/logsink.go |
| +++ b/internal/activator/logsink.go |
| @@ -53,6 +53,14 @@ func memberSafeWakeReason(err error) string { |
| // The stale-socket wake trap (#129): every later wake fails until |
| // the leftover socket goes away. Say that, not the bind syscall. |
| return "wake failed: instance socket already in use — a previous run's socket was left behind (this wake will keep failing until the platform clears it)" |
| + case strings.Contains(s, release.StaticHeadUnderInstance): |
| + // The one wake failure `carlos channels` appears to CONTRADICT: the |
| + // channel does have a head, it is just a static one under a route |
| + // that execs a binary (skills#29). materialize's sentence already |
| + // names the app, the version, the channel and both commands out of |
| + // it — it is composed for a member, so it is passed through whole |
| + // rather than summarised into something less actionable. |
| + return "wake failed: " + RedactPaths(s) |
| case strings.Contains(s, "no adoptable head"): |
| return "wake failed: nothing is promoted for this instance's channel (or the head is still bake-held) — promote a build, or check `carlos channels`" |
| } |
diff --git a/internal/activator/logsink_test.go b/internal/activator/logsink_test.go| index 8a9e0309d..85215a6c6 100644 |
| --- a/internal/activator/logsink_test.go |
| +++ b/internal/activator/logsink_test.go |
| @@ -30,6 +30,22 @@ func TestMemberSafeWakeReason(t *testing.T) { |
| []string{"nothing is promoted", "carlos channels"}, |
| nil, |
| }, |
| + { |
| + // skills#29: a static head under an instance route. This one is |
| + // ordered BEFORE the "no adoptable head" case in the renderer, |
| + // and the ordering is the whole point — materialize's sentence |
| + // contains both markers, and matching the generic one first |
| + // would throw away the only diagnosis that names the real |
| + // problem and print the reassuring falsehood instead. |
| + "a static release under an instance route", |
| + errors.New(`materialize techarena.bak.platform.tito.io: the head is a static release: techarena 0.1.0 ` + |
| + `is promoted on edge, but a static site is served straight off the channel pointer and has no ` + |
| + `binary to run, so this route can never wake into it. A static site needs a static route: ` + |
| + "`carlos instances delete --app techarena --host techarena.bak.platform.tito.io`, then " + |
| + "`carlos deploy --app techarena --kind static --host techarena.bak.platform.tito.io <site-dir>`"), |
| + []string{"static release", "--kind static", "0.1.0", "carlos deploy"}, |
| + []string{"nothing is promoted"}, // the contradiction that cost a real session its afternoon |
| + }, |
| { |
| "unknown errors keep meaning, lose layout", |
| errors.New(`seed h: fork/exec /opt/carlos/versions/ledgerpay/1.2.3/ledgerpay: permission denied`), |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index f1c4b2daa..a041381cc 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -641,6 +641,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadRequest, "invalid channel name") |
| return |
| } |
| + if ref := s.staticHeadRefusal(ctx, acct.Sqid, app, channel, strings.TrimSpace(req.Kind), host); ref != nil { |
| + jsonError(w, ref.Status, ref.Msg) |
| + return |
| + } |
| rec := provision.Record{ |
| Schema: 1, |
| Host: host, |
| @@ -806,6 +810,50 @@ func (s *Server) instanceChannel(ctx context.Context, sqid, app string, m *provi |
| return release.DefaultPipelineChannels()[0].Name, nil |
| } |
| +// staticHeadRefusal refuses the one instance record that can never work: a |
| +// process-backed route on a channel whose head is a STATIC release. |
| +// |
| +// The record would be created happily, provisioned into a real route, and |
| +// then fail every wake forever — a static release's manifest carries a "site" |
| +// tar and no build for any box's platform, so nothing is ever staged and no |
| +// live symlink is ever written. What the member saw before this door existed |
| +// was "nothing is promoted for this instance's channel" against a `carlos |
| +// channels` that showed the promote plainly there, with no path from either |
| +// to the real problem (skills#29). internal/release's noHeadReason now names |
| +// that state honestly when a box is already in it; this is the door that |
| +// stops it being reachable. |
| +// |
| +// Only the exec shapes are gated. A "service" record proxies to declared |
| +// upstreams and a "static" record reads the channel pointer directly — |
| +// neither stages a binary, so neither has anything to mismatch. An empty kind |
| +// is the default, which IS "instance": that is precisely the request the |
| +// reporter made. |
| +// |
| +// The check spends two bucket reads on a create, and only a create. Every |
| +// failure to read degrades to nil — this is a guard against a shape mistake, |
| +// not a rail against a hostile caller, and a bucket hiccup must not stop an |
| +// ordinary member declaring an ordinary instance. A brand-new app with |
| +// nothing promoted yet reads as nil too, which is the common first-instance |
| +// case and stays exactly as fast as it was. |
| +func (s *Server) staticHeadRefusal(ctx context.Context, sqid, app, channel, kind, host string) *doorRefusal { |
| + if kind != "" && kind != "instance" { |
| + return nil |
| + } |
| + ptr, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, sqid, app, channel) |
| + if err != nil || ptr == nil || ptr.Version == "" { |
| + return nil |
| + } |
| + m, err := release.GetManifest(ctx, s.cfg.Releases, sqid, app, ptr.Version) |
| + if err != nil || m == nil || m.Kind != "static" { |
| + return nil |
| + } |
| + return &doorRefusal{http.StatusUnprocessableEntity, |
| + "channel " + channel + " serves a static release (" + ptr.Version + "), and an instance runs a process — " + |
| + "a static site is served straight off the channel pointer, so it needs no instance at all. " + |
| + "Pass --kind static, or let one command do the whole thing: carlos deploy --app " + app + |
| + " --kind static --host " + host + " <site-dir>"} |
| +} |
| + |
| // handleAPIInstanceUpdate repoints a live instance's UPSTREAM SET, probe |
| // config and (Task 3, multi-region-steering plan) DNS STEERING opt-in — the |
| // fields of a record that are meant to change while the instance keeps |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 78e759078..c6c623267 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -2201,6 +2201,103 @@ func TestAPIInstanceCreateArmedPlacementGate(t *testing.T) { |
| // single-box deployment states the intent harmlessly and becomes HA the day |
| // it arms a second edge. `carlos steering --app x --host y off` is the |
| // escape hatch, and nothing here ever converges an existing record. |
| +// shipStaticRelease ships a STATIC release: a "site" artifact and no build |
| +// for any box's platform. That absence is the whole bug — a box stages |
| +// nothing, writes no live symlink, and every wake of an instance route on |
| +// this channel fails forever (skills#29). |
| +func shipStaticRelease(t *testing.T, fx *consoleFixture, app, version string) { |
| + t.Helper() |
| + m := release.Manifest{ |
| + Schema: 1, App: app, Version: version, Kind: "static", |
| + BuiltAt: "2026-09-01T23:16:21Z", |
| + Artifacts: map[string]release.Artifact{"site": {File: "site.tar"}}, |
| + } |
| + if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, m, |
| + map[string][]byte{"site": []byte("<h1>" + version + "</h1>")}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// skills#29, at the door that could have prevented it. A member shipped a |
| +// static site, promoted it, and then declared an INSTANCE for it — the kind |
| +// this door defaults to. The record was created happily and the route it |
| +// provisioned could never wake, because a static release carries no binary to |
| +// run. What they saw was "nothing is promoted for this instance's channel" |
| +// against a `carlos channels` showing the promote plainly there. |
| +// |
| +// The refusal has to do more than refuse: an agent that reads it must be able |
| +// to act on it without another round of discovery, so it names both ways out. |
| +func TestAPIInstanceCreateRefusesAnInstanceOnAStaticHead(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-static-head") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + shipStaticRelease(t, fx, "titogo", "0.1.0") |
| + seedChannel(t, fx, "titogo", "edge", "0.1.0") |
| + host := "titogo." + fx.sqid + ".oncarlos.test" |
| + |
| + // No "kind" at all — the CLI's default, and exactly the request that was |
| + // made in the reported session. |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","channel":"edge"}`) |
| + if status != http.StatusUnprocessableEntity { |
| + t.Fatalf("create = %d %s, want 422 — an instance on a static head can never wake", status, out) |
| + } |
| + for _, want := range []string{"static release", "0.1.0", "--kind static", "carlos deploy"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("refusal %q does not name %q — the member is refused without being told the way out", out, want) |
| + } |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", host); err == nil { |
| + t.Error("the refused create wrote a record anyway") |
| + } |
| + |
| + // The way out the refusal names actually works, in the same fixture. |
| + status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","channel":"edge","kind":"static"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("static create = %d %s, want 201 — the refusal named a door that is shut", status, out) |
| + } |
| +} |
| + |
| +// The guard is about the SHAPE mismatch, not about static apps: an app whose |
| +// channel head is an ordinary binary release still gets an ordinary instance |
| +// with no kind named, which is most creates on the platform. |
| +func TestAPIInstanceCreateAllowsAnInstanceOnABinaryHead(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-binary-head") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + shipVersion(t, fx, "titogo", "v1") |
| + seedChannel(t, fx, "titogo", "edge", "v1") |
| + host := "titogo." + fx.sqid + ".oncarlos.test" |
| + |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","channel":"edge"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// A brand-new app with nothing promoted anywhere is the FIRST-instance case, |
| +// and it must stay exactly as it was: nothing to read, nothing to refuse. |
| +func TestAPIInstanceCreateUnaffectedWithNothingPromoted(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-no-head") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + host := "titogo." + fx.sqid + ".oncarlos.test" |
| + |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","channel":"edge"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201 — the first instance of an app that has shipped nothing", status, out) |
| + } |
| +} |
| + |
| func TestAPIInstanceCreateStaticSteersByDefault(t *testing.T) { |
| fx, member := instanceFixture(t, "code-inst-static-steer") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
diff --git a/internal/release/materialize.go b/internal/release/materialize.go| index 67fa70d2a..c3617e128 100644 |
| --- a/internal/release/materialize.go |
| +++ b/internal/release/materialize.go |
| @@ -53,7 +53,7 @@ func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub e |
| target, err := os.Readlink(link) |
| switch { |
| case err != nil && os.IsNotExist(err): |
| - return fmt.Errorf("materialize %s: no adoptable head (nothing promoted for its channel, or the head is bake-held)", host) |
| + return fmt.Errorf("materialize %s: %s", host, noHeadReason(ctx, reg, s, pub, host)) |
| case err != nil: |
| return fmt.Errorf("materialize %s: read live link %s: %w", host, link, err) |
| } |
| @@ -64,3 +64,56 @@ func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub e |
| } |
| return nil |
| } |
| + |
| +// noAdoptableHead is what an absent live link means when nothing more |
| +// specific can be PROVEN: the channel has no pointer, the pointer would not |
| +// verify, or its head is sitting out a bake window. All three leave the same |
| +// absence behind and all three are answered the same way — wait, or promote. |
| +const noAdoptableHead = "no adoptable head (nothing promoted for its channel, or the head is bake-held)" |
| + |
| +// StaticHeadUnderInstance is the marker internal/activator matches to render |
| +// the one absent-link cause that is neither "wait" nor "promote": a channel |
| +// whose head is a STATIC release, under a route that execs a binary. |
| +// Exported so the member-facing renderer and this diagnosis cannot drift into |
| +// matching on different words. |
| +const StaticHeadUnderInstance = "the head is a static release" |
| + |
| +// noHeadReason explains an absent live link, in the member's terms where it |
| +// can and in the generic terms above where it cannot. |
| +// |
| +// It exists for one case the generic sentence gets actively WRONG. A static |
| +// release's manifest keys its artifact "site", not "<goos>-<goarch>", so |
| +// ensureStaged refuses to stage it ("no linux-arm64 build — skipping"), the |
| +// pass logs that box-side and moves on, and the link is never written. The |
| +// member is then told nothing is promoted for the channel while `carlos |
| +// channels` shows the promote that plainly did happen — two contradictory |
| +// true statements, and no way from either to the actual problem, which is |
| +// that an instance route can never serve a static site (skills#29). |
| +// |
| +// The route class is checked, not assumed: only a Kind=="instance" route |
| +// execs a binary, and only for one is the mismatch this names real. |
| +// |
| +// Two bucket reads, on the failure path only. A wake that already failed can |
| +// afford them, and nothing on the success path pays anything. Every step |
| +// degrades to the generic reason — a diagnosis that cannot be made must not |
| +// become a second, worse error on top of the first. |
| +func noHeadReason(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, host string) string { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil || rt.Kind != "instance" { |
| + return noAdoptableHead |
| + } |
| + acct, channel := RouteAccount(*rt), routeChannel(*rt) |
| + ptr, err := Channel(ctx, s, pub, acct, rt.App, channel) |
| + if err != nil || ptr == nil || ptr.Version == "" { |
| + return noAdoptableHead // genuinely nothing promoted, or a pointer that would not verify |
| + } |
| + m, err := GetManifest(ctx, s, acct, rt.App, ptr.Version) |
| + if err != nil || m == nil || m.Kind != "static" { |
| + return noAdoptableHead |
| + } |
| + return fmt.Sprintf("%s: %s %s is promoted on %s, but a static site is served straight off the "+ |
| + "channel pointer and has no binary to run, so this route can never wake into it. "+ |
| + "A static site needs a static route: `carlos instances delete --app %s --host %s`, then "+ |
| + "`carlos deploy --app %s --kind static --host %s <site-dir>`", |
| + StaticHeadUnderInstance, rt.App, ptr.Version, channel, rt.App, host, rt.App, host) |
| +} |
diff --git a/internal/release/materialize_test.go b/internal/release/materialize_test.go| index b60745944..e9edf2b60 100644 |
| --- a/internal/release/materialize_test.go |
| +++ b/internal/release/materialize_test.go |
| @@ -179,3 +179,79 @@ func TestMaterializeHostNothingPromoted(t *testing.T) { |
| t.Fatalf("live = %q, want absent", v) |
| } |
| } |
| + |
| +// shipStatic ships a STATIC release: one "site" artifact and no build for any |
| +// box's platform, which is the whole shape of the bug below. |
| +func shipStatic(t *testing.T, s Store, acct, app, version string, body []byte) { |
| + t.Helper() |
| + m := Manifest{ |
| + App: app, Version: version, Kind: "static", |
| + Artifacts: map[string]Artifact{"site": {}}, |
| + } |
| + if err := Ship(context.Background(), s, acct, m, map[string][]byte{"site": body}); err != nil { |
| + t.Fatalf("Ship static %s/%s: %v", app, version, err) |
| + } |
| +} |
| + |
| +// TestMaterializeHostNamesAStaticHeadUnderAnInstance is skills#29, as it |
| +// happened: a static site shipped and promoted, and an instance record |
| +// created for it (the CLI's --kind default). The wake can never succeed — |
| +// there is no binary in a static release to exec — but before this the member |
| +// was told "nothing is promoted for this instance's channel" while `carlos |
| +// channels` showed the promote plainly there. Two true-sounding statements |
| +// that contradict each other, and no route from either to the real problem. |
| +// |
| +// So the assertion is on the DIAGNOSIS, not merely on the failure: the error |
| +// must name the static head, and must not repeat the "nothing promoted" claim |
| +// that sent a real session hunting a bake window that did not exist. |
| +func TestMaterializeHostNamesAStaticHeadUnderAnInstance(t *testing.T) { |
| + env := newMatFixture(t) |
| + ctx := context.Background() |
| + |
| + shipStatic(t, env.store, "bab", "matapp", "0.1.0", []byte("<h1>a site</h1>")) |
| + if err := Promote(ctx, env.store, env.priv, "bab", "matapp", "canary/dev", "0.1.0"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, env.reg, "mat.test", "matapp", "canary/dev", false) |
| + |
| + err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test") |
| + if err == nil { |
| + t.Fatal("an instance route on a static head must fail the wake, not report success") |
| + } |
| + if !strings.Contains(err.Error(), StaticHeadUnderInstance) { |
| + t.Errorf("error %q does not name the static head — the member is left with the\n"+ |
| + "contradiction between the wake and `carlos channels` that this diagnosis exists to end", err) |
| + } |
| + if strings.Contains(err.Error(), "nothing promoted for its channel") { |
| + t.Errorf("error %q still claims nothing is promoted — 0.1.0 IS promoted on this channel", err) |
| + } |
| + // The way out has to be IN the message: a member reading this in |
| + // `carlos logs` has no other source for it. |
| + for _, want := range []string{"0.1.0", "carlos deploy", "--kind static"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("error %q does not name %q", err, want) |
| + } |
| + } |
| +} |
| + |
| +// The generic reason must survive for every cause it is still right about: a |
| +// BINARY head that is simply not promoted stays "nothing promoted", and must |
| +// never be mislabelled as a static-shape problem. |
| +func TestMaterializeHostKeepsTheGenericReasonForABinaryApp(t *testing.T) { |
| + env := newMatFixture(t) |
| + ctx := context.Background() |
| + |
| + shipRelease(t, env.store, "bab", "matapp", "abc1234", adoptPlatform, []byte("never promoted")) |
| + addRoute(t, env.reg, "mat.test", "matapp", "canary/dev", false) |
| + |
| + err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test") |
| + if err == nil { |
| + t.Fatal("a channel with nothing promoted must error") |
| + } |
| + if !strings.Contains(err.Error(), noAdoptableHead) { |
| + t.Errorf("error %q lost the generic reason", err) |
| + } |
| + if strings.Contains(err.Error(), StaticHeadUnderInstance) { |
| + t.Errorf("error %q blames a static head on an app that has only ever shipped binaries", err) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 5a4cc6d0f..71aa9a3c6 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -161,8 +161,9 @@ Your apps — ship and run software on a CARLOS deployment |
| fleet or back to "" |
| ship publish a release of an app — --label and --notes name it, |
| and --version is optional once a version target is set |
| - promote move a version up the ladder: canary → edge → beta → stable |
| - (reaching stable cuts the release's semver tag) |
| + promote move a shipped version onto one of the app's own channels — |
| + the channel is optional when the app has only one |
| + ("carlos channels" lists them; "carlos pipeline" orders them) |
| deploy ship + promote + wait until the URL serves the new version |
| restart cycle an app's instances — no new version, no config change |
| schedule recurring or one-shot work per app: ls | set | rm | run |