Merge pull request #119 from carlosframework/restart-verb
carlos restart: cycle an app's processes with no box access (#113)
22 files changed,
+2441
−33
apimode.go+39 −0internal/console/apicli.go+86 −0internal/console/apirestart_test.go+177 −0internal/console/authcli.go+5 −0internal/host/agent.go+81 −1internal/host/bootstrap.go+6 −4internal/host/host_test.go+2 −0internal/host/probes.go+19 −0internal/host/restart.go+650 −0internal/host/restart_test.go+706 −0internal/host/units/carlos-restart.path+27 −0internal/host/units/carlos-restart.service+53 −0internal/release/restartgen.go+107 −0internal/release/restartgen_test.go+135 −0internal/status/contract_test.go+9 −2internal/status/dryrun.go+10 −8internal/status/fleet.go+14 −8internal/status/snapshot.go+26 −10internal/status/status_test.go+47 −0main.go+3 −0restart.go+86 −0restart_test.go+153 −0
diff --git a/apimode.go b/apimode.go| index d309e3759..ad4daf82e 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -414,6 +414,45 @@ func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel strin |
| return apiErrorFromBody(status, body) |
| } |
| +// apiRestartResult is what `carlos restart` prints: the console's CANONICAL |
| +// account sqid and app name, since the segment the caller typed may have been |
| +// a name resolved server-side. |
| +type apiRestartResult struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| + |
| +// restart asks the console to request a process cycle for app on every box |
| +// that serves it (issue #113). Mirrors trashApp's shape — postJSON, a 200 |
| +// decodes, anything else is apiErrorFromBody's job — with ONE addition, the |
| +// 404. |
| +// |
| +// A 404 here is genuinely ambiguous, and pretending otherwise would be a lie |
| +// in one direction or the other. An old console has no /restart route at all, |
| +// so net/http's mux answers exactly the same byte-identical "404 page not |
| +// found" apiAccountApp gives an unknown account, someone else's account, or an |
| +// app that isn't there — there is nothing in the response to tell them apart. |
| +// So the message names BOTH, shortest fix first, the same way restoreApp |
| +// widens its own 404 rather than inventing certainty it doesn't have. |
| +func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRestartResult, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), struct{}{}) |
| + if err != nil { |
| + return apiRestartResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiRestartResult{}, fmt.Errorf("%w — or this console predates `carlos restart` "+ |
| + "(no restart door on it at all), in which case redeploy the console first", notFoundErr()) |
| + } |
| + return apiRestartResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiRestartResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiRestartResult{}, fmt.Errorf("restart: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // channels reads one app's channel ladder through the console. |
| func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]apiChannel, error) { |
| status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels")) |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index db48921fd..90b54edb6 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -469,6 +469,92 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"}) |
| } |
| +// handleAPIRestart answers `carlos restart`: touch the app's restart |
| +// generation, then bump the serial so every box serving it cycles that app's |
| +// processes within seconds (issue #113). No version moves, no config is |
| +// written, no bake window applies — this is the member-facing way to cycle a |
| +// wedged instance, which until now meant a box login the platform's own design |
| +// rule forbids ("every carlos command works for a user with zero infra |
| +// access"). |
| +// |
| +// The BUMP is the delivery mechanism, not decoration. Boxes do not poll |
| +// restart generations: the agent's existing 2s serial poll is what makes it |
| +// look, exactly as with a promote or a config write (internal/host/restart.go). |
| +// A touch that landed without a bump would still converge — on the hourly root |
| +// pass, up to an hour later — which is the floor, not the promise this door |
| +// makes. |
| +// |
| +// The doors, in order, and why this order — handleAPITrashApp's exactly: |
| +// |
| +// token — cliUser's one 401, before any name is resolved. |
| +// account+app 404 — apiAccountApp: an unknown account, someone else's |
| +// account and an app that isn't there are one answer, so |
| +// probing this door teaches a stranger nothing. It also |
| +// means a TRASHED app cannot be restarted: the live-apps |
| +// lookup simply doesn't answer for it. |
| +// rate limit — the shared `write` budget, keyed on the ADDRESS like |
| +// promote's: a budget belongs to the person, not the |
| +// terminal. This door needs it more than most — its whole |
| +// effect is to make processes stop and start, so an |
| +// unbudgeted one would be a restart loop anyone with a |
| +// token could aim at their own app. |
| +// Releases nil — 503. The request IS a bucket object; a console with no |
| +// bucket has nowhere to put it and must not answer |
| +// "requested". |
| +// touch + bump — the object, then the announcement, in that order (a bump |
| +// for a request that failed to land would wake the fleet |
| +// for nothing). |
| +// |
| +// Audited, unlike a read: this stops and starts customer processes, which |
| +// anyone reading audit rows must be able to see. |
| +// |
| +// No body is read. There is nothing to say beyond the app already named in the |
| +// path — there is no restart "options" — and a bearer request carries no |
| +// cookie for a cross-site form to forge. |
| +// |
| +// No per-host scoping. The generation is per APP, so every box serving it |
| +// cycles its own routes for that app; a -host flag would need a per-host |
| +// object and a way to say what happens to the hosts it doesn't name, and |
| +// "restart my app" is the request people actually have. Boxes skip their |
| +// hibernating routes on their own (internal/host/restart.go) — nothing here |
| +// wakes anything. |
| +func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — this console cannot request a restart") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| + // Logged, not returned: a store error carries bucket names, endpoints |
| + // and request ids. The caller gets the fixed sentence every other |
| + // bucket failure here gets, and a retry is safe — the touch is |
| + // idempotent in the only sense that matters (one more request, one |
| + // more cycle). |
| + log.Printf("console: api restart %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "restart", "requested a restart (cli)") |
| + // The request is in the bucket; this is what makes boxes LOOK at it — they |
| + // read restart generations only when the serial moves. The difference |
| + // between "seconds" and "up to an hour" for the one verb people type when |
| + // something is stuck. |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "requested": true, "account": acct.Sqid, "app": app, |
| + }) |
| +} |
| + |
| // ---- trash and restore ------------------------------------------------------ |
| // handleAPITrashApp answers `carlos apps delete`: it moves an app to the |
diff --git a/internal/console/apirestart_test.go b/internal/console/apirestart_test.go| new file mode 100644 |
| index 000000000..b099d58ba |
| --- /dev/null |
| +++ b/internal/console/apirestart_test.go |
| @@ -0,0 +1,177 @@ |
| +package console |
| + |
| +// The `carlos restart` door (issue #113). What is pinned here, in the order it |
| +// would hurt if lost: the request actually lands in the bucket AND is |
| +// announced (a request nobody hears is a restart that never happens); a |
| +// stranger learns nothing from probing it; and the budget that keeps it from |
| +// becoming a restart loop is spent per person. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// restartGenEtag is what every box watches: "" when nobody has ever asked. |
| +func restartGenEtag(t *testing.T, fx *consoleFixture, app string) string { |
| + t.Helper() |
| + _, etag, err := fx.rel.Get(context.Background(), release.RestartGenKey(release.OpsAccount, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + t.Fatalf("read restart generation: %v", err) |
| + } |
| + return etag |
| +} |
| + |
| +func restartFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A MEMBER, not an owner: cycling your own app's processes is a member |
| + // action, like promote below stable. |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// TestAPIRestartTouchesTheGenerationAndAnnouncesIt is the whole door: the |
| +// object a box watches moves, the serial says "look now", and the answer names |
| +// the canonical account (the caller may have typed a name). |
| +func TestAPIRestartTouchesTheGenerationAndAnnouncesIt(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-roundtrip") |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("restart generation exists before any request: %q", got) |
| + } |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("restart = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Requested bool `json:"requested"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatalf("restart body %s: %v", out, err) |
| + } |
| + if !res.Requested || res.Account != "bab" || res.App != "titogo" { |
| + t.Fatalf("restart body = %+v, want requested for bab/titogo", res) |
| + } |
| + first := restartGenEtag(t, fx, "titogo") |
| + if first == "" { |
| + t.Fatal("no restart generation in the bucket after a 200 — the boxes will never hear about it") |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after a restart request = %d, want 1 (otherwise boxes wait out the hourly floor)", got) |
| + } |
| + |
| + // A SECOND request must move the etag again: change detection is by etag, |
| + // so a byte-identical rewrite would be a restart nobody performs. |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("second restart = %d %s", status, out) |
| + } |
| + if second := restartGenEtag(t, fx, "titogo"); second == first { |
| + t.Fatalf("etag %q unchanged after a second request", second) |
| + } |
| + if got := serial(t, fx); got != 2 { |
| + t.Fatalf("serial after two requests = %d, want 2", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartMemberGate: a non-member's refusal is byte-identical with an |
| +// unknown account's and an unknown app's — the same gate, in the same place, |
| +// as every other door here. Probing this one must not reveal that an app |
| +// exists, let alone restart it. |
| +func TestAPIRestartMemberGate(t *testing.T) { |
| + fx, _ := restartFixture(t, "code-restart-gate-member") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-restart-gate-eve") |
| + |
| + status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, eve) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("restart as non-member = %d, want 404", status) |
| + } |
| + if body != "404 page not found\n" { |
| + t.Fatalf("restart as non-member body = %q, want the stdlib http.NotFound output", body) |
| + } |
| + // An unknown app in an account eve IS a member of would have to answer the |
| + // same; here the account itself is not hers, and neither answer moved |
| + // anything. |
| + if got := restartGenEtag(t, fx, "titogo"); got != "" { |
| + t.Fatal("a refused request touched the restart generation") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a refused request = %d, want 0", got) |
| + } |
| +} |
| + |
| +// TestAPIRestartNoTokenIs401: the token gate is first, before any name is |
| +// resolved. |
| +func TestAPIRestartNoTokenIs401(t *testing.T) { |
| + fx, _ := restartFixture(t, "code-restart-notoken") |
| + if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", "", ""); status != http.StatusUnauthorized { |
| + t.Fatalf("restart with no token = %d, want 401", status) |
| + } |
| +} |
| + |
| +// TestAPIRestartIsRateLimited: this door's whole effect is to stop and start |
| +// processes, so the budget is what stands between a token and a restart loop |
| +// aimed at a live app. Keyed on the address, like every other write here. |
| +func TestAPIRestartIsRateLimited(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-limit") |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusOK { |
| + t.Fatalf("first restart = %d %s, want 200", status, out) |
| + } |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("second restart on an exhausted budget = %d %s, want 429", status, out) |
| + } |
| + // A second TOKEN for the same person shares the budget: the principal is |
| + // the person, not the terminal. |
| + other := cliToken(t, fx, "alice@keymail.dev", "code-restart-limit-2") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", other, ""); status != http.StatusTooManyRequests { |
| + t.Fatalf("restart from a second token = %d %s, want 429", status, out) |
| + } |
| +} |
| + |
| +// TestAPIRestartRefusesATrashedApp: the live-apps resolver is the gate, so an |
| +// app in the trash answers 404 like anything else that isn't there — there are |
| +// no processes to cycle, and this door must not become the way to ask whether |
| +// somebody's app was deleted. |
| +func TestAPIRestartRefusesATrashedApp(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-trashed") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, ""); status != http.StatusOK { |
| + t.Fatalf("trash = %d %s", status, out) |
| + } |
| + if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusNotFound { |
| + t.Fatalf("restart of a trashed app = %d %s, want 404", status, body) |
| + } |
| +} |
| + |
| +// TestAPIRestartWithoutABucketIs503: the request IS a bucket object. A console |
| +// with nowhere to put it must say so rather than answer "requested" for |
| +// something no box will ever see. |
| +func TestAPIRestartWithoutABucketIs503(t *testing.T) { |
| + fx, token := restartFixture(t, "code-restart-nobucket") |
| + fx.srv.cfg.Releases = nil |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "") |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("restart with no bucket = %d %s, want 503", status, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 0f3351d02..29b0facbb 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -225,6 +225,11 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip) |
| + // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| + // with no version and no config to push — the same write-door class as |
| + // promote and rollback, and the only one whose subject is a running |
| + // process rather than a pointer. |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restart", s.handleAPIRestart) |
| // The version-target, tags and releases-list API (apiversion.go, |
| // release versioning spec 2026-08-08). GET .../releases coexists with |
| // the POST above on the SAME path — a distinct method route, not a |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 8b6928fb7..a6612ebfc 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -719,6 +719,7 @@ type fastDeps struct { |
| restart func(string) error |
| passOpts []release.PassOption // fastPassOpts — every sweep pass carries all of them |
| fullPass func(ctx context.Context) // reconcile → adopt → config sync, or a test hook |
| + restartLeg func(ctx context.Context) // the `carlos restart` check (issue #113), or a test hook |
| lastSerial int64 |
| seeded bool |
| lastFull time.Time |
| @@ -855,6 +856,13 @@ func (d *fastDeps) backoffFor(host string) time.Duration { return d.step[host] } |
| // is deferred by NOT recording the acted serial, so the next tick past |
| // the window re-fires. Read errors are tolerated silently (one log |
| // line per state change, never per tick). |
| +// The restart leg (issue #113) rides that same move, AFTER the pass set: |
| +// a bump can carry a promote and a restart request at once, and cycling |
| +// processes before their new version and config land would cycle them onto |
| +// the old ones. It is a separate call rather than a fourth leg inside |
| +// fullPass because it is not release convergence — it restarts what is |
| +// already correct — and because it must still run on a box with no |
| +// CARLOS_SECRETS_KEY, which fullPass's config leg silently skips. |
| // 2. Fast sweep: fastMembers, and only when non-empty one PassFilter over |
| // exactly those hosts — an empty sweep costs zero store reads. After |
| // the sweep, members still unstaged back off; staged members clear |
| @@ -882,6 +890,9 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) { |
| d.serialBroken = false |
| if doc.Serial != d.lastSerial && now.Sub(d.lastFull) >= fastFullClamp { |
| d.fullPass(ctx) |
| + if d.restartLeg != nil { |
| + d.restartLeg(ctx) |
| + } |
| d.lastSerial = doc.Serial |
| d.lastFull = now |
| } |
| @@ -950,6 +961,13 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) { |
| // Built once, used by both pass shapes below — see fastPassOpts for the |
| // DO-NOT list and why the stage requester is per-loop, not per-tick. |
| opts := fastPassOpts(dd, stageRequester(dd), restart) |
| + // The restart check's memory (issue #113), seeded ONCE here — one read per |
| + // app at arming and never again except on a serial move, which is what |
| + // keeps an idle box at exactly one serial GET per tick. See restartSeed for |
| + // why the baseline cannot wait for the first request to establish itself. |
| + restartCache := map[[2]string]string{} |
| + restartSeed(ctx, reg, store, dd, restartCache) |
| + restartBounce := bounceFn(svc) |
| d := &fastDeps{ |
| reg: reg, store: store, pub: pub, optDir: optDir, |
| restart: restart, passOpts: opts, |
| @@ -970,6 +988,17 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) { |
| } |
| } |
| }, |
| + // The restart leg: this process's own children bounced here, everything |
| + // unit-shaped relayed to a root pass. Deliberately NOT inside fullPass |
| + // above — see fastTick's comment for both reasons. |
| + restartLeg: func(ctx context.Context) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("agent: restart check: list routes: %v", err) |
| + return |
| + } |
| + restartRequestLeg(ctx, routes, store, dd, restartBounce, restartCache) |
| + }, |
| } |
| log.Printf("agent: fast converge loop armed (2s + jitter: serial poll + canary/* + unstaged routes)") |
| for { |
| @@ -1415,6 +1444,10 @@ func statusLoop(reg *registry.Registry) { |
| // carlos-adopt.path is a box whose unit-backed config silently |
| // waits for the hourly timer (issue #44). |
| ConfigPathRunning: configPathActive(), |
| + // A third, for the restart trigger: an enabled-but-unstarted |
| + // carlos-restart.path is a box where `carlos restart` answers |
| + // "requested" and nothing cycles until the hourly pass (issue #113). |
| + RestartPathRunning: restartPathActive(), |
| // ExpiredCanaries answers spec §5 for this box's own routes; see |
| // countExpiredCanaries and the canaryStore/canaryPub comment above. |
| ExpiredCanaries: countExpiredCanaries(ctx, routes, canaryStore, canaryPub, time.Now()), |
| @@ -1689,11 +1722,22 @@ func openDeployStore(ctx context.Context) (release.Store, error) { |
| // seconds of an unrelated write, and would fire the canary-TTL nag per |
| // config write instead of hourly — the "hourly root pass alone" split |
| // release/adopt.go and the nag comment below both depend on. |
| +// |
| +// -restart-only runs the RESTART-REQUEST half alone (issue #113): compare each |
| +// app's restart generation against this box's marks and cycle the ones that |
| +// moved, nothing else. That is what carlos-restart.service execs, and it is |
| +// separated from -config-only for the same reason -config-only is separated |
| +// from the full pass: the trigger behind it is a per-request path unit, and a |
| +// request to cycle one app's processes must not stage a release, run the |
| +// capability converge, or nag about canaries. It deliberately needs NO release |
| +// key and NO secrets key — restarting verifies no signature and renders no |
| +// config — so a box missing either still honours it. |
| func RunAdopt(args []string) { |
| fs := flag.NewFlagSet("adopt", flag.ExitOnError) |
| regPath := fs.String("registry", filepath.Join(dataDir(), "registry.db"), "registry database") |
| configOnly := fs.Bool("config-only", false, "sync unit-backed config and restart changed routes only: no release adoption, no canary nag") |
| stageOnly := fs.Bool("stage-only", false, "adopt releases without the canary nag: the agent-requested pass (carlos-stage.service)") |
| + restartOnly := fs.Bool("restart-only", false, "cycle the routes of apps whose restart generation moved, and nothing else: the agent-requested pass (carlos-restart.service)") |
| _ = fs.Parse(args) |
| reg, err := registry.Open(*regPath) |
| if err != nil { |
| @@ -1717,6 +1761,24 @@ func RunAdopt(args []string) { |
| // construction. It is handed the same callback anyway because there should |
| // be ONE restart dispatch on this pass, not two that could drift. |
| restart := rootRestartRoute(reg) |
| + // The restart-request half, and the ONE place it dispatches from — |
| + // including for exec-backed routes, whose children belong to the agent: |
| + // rootRestartRoute's try-restart arm reaches a carlos-app@ unit, and an |
| + // exec child that this pass cannot cycle is cycled by nothing else, which |
| + // is a real limit of the -restart-only path recorded in restartPass. |
| + // |
| + // -restart-only returns here: no config sync, no capability converge, no |
| + // release.Pass, no nag, and — above all — no release public key, which is |
| + // parsed below and is fatal when unusable. A restart request must not fail |
| + // on a key it never needs. |
| + if *restartOnly { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Fatalf("adopt: list routes: %v", err) |
| + } |
| + restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| + return |
| + } |
| // Parsed here, before config sync, exactly as the full pass always has |
| // (a box with an unusable release key must die before it converges |
| // anything), but SKIPPED entirely for -config-only: that mode never |
| @@ -1745,10 +1807,28 @@ func RunAdopt(args []string) { |
| if *configOnly { |
| // Everything below is release adoption and its reporting — the half |
| // carlos-config-sync.service must never reach, however often a |
| - // config write triggers it. |
| + // config write triggers it. The restart half is below for the same |
| + // reason: a config write is not a restart request, and running the |
| + // comparison here would only move it earlier by minutes at the cost of |
| + // firing on a trigger that has nothing to do with it. |
| return |
| } |
| + // The restart-request half again (issue #113), here as the FLOOR under |
| + // carlos-restart.path: that relay can drop an event that lands while the |
| + // service is mid-run, exactly as the config one can, and a dropped restart |
| + // with no floor would be a request that never happens at all. |
| + // |
| + // BEFORE release.Pass deliberately. That call is fatal on error, so a box |
| + // with an unstageable release would otherwise never reach this line — and |
| + // a wedged instance is most worth cycling precisely on a box where |
| + // something else is already wrong. |
| + if routes, err := reg.List(); err != nil { |
| + log.Printf("adopt: list routes for the restart pass: %v", err) |
| + } else { |
| + restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart) |
| + } |
| + |
| // Step 2 (spec §4d): the grant-drift capability converge, BETWEEN config |
| // sync and release adoption. Before release.Pass because it repairs the |
| // shape of what is ALREADY live — a revoked grant, a deleted drop-in, a |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 1baf02394..f5bb7765a 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -58,14 +58,16 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" |
| // enabledUnits are what a live bootstrap turns on: the always-on front door, |
| -// the hourly adoption timer, and the two path units that let the sandboxed |
| +// the hourly adoption timer, and the three path units that let the sandboxed |
| // agent ask a root pass for the halves it cannot do itself: carlos-adopt.path |
| -// (config fast convergence, issue #44) and carlos-stage.path (release staging |
| -// — the agent sees a promotion in seconds but cannot write /opt). The litestream + restore-verify units install |
| +// (config fast convergence, issue #44), carlos-stage.path (release staging |
| +// — the agent sees a promotion in seconds but cannot write /opt) and |
| +// carlos-restart.path (a member's `carlos restart`, issue #113 — the agent |
| +// bounces its own exec children but cannot cycle a unit). The litestream + restore-verify units install |
| // everywhere but stay inert until an operator stages /data/litestream.env and |
| // generates /etc/litestream.yml (`carlos ops litestream`) — a box with no |
| // replication bucket is unaffected. |
| -var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path"} |
| +var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path"} |
| // systemctlPlan is the exact systemctl sequence a live bootstrap runs — and |
| // the same sequence a staging run (-root <dir>) PRINTS instead of running, so |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index e432768fc..52c7d1a4e 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -33,6 +33,8 @@ var expectedUnits = []string{ |
| "carlos-config-sync.service", |
| "carlos-edge.service", |
| "carlos-litestream.service", |
| + "carlos-restart.path", |
| + "carlos-restart.service", |
| "carlos-restore-verify.service", |
| "carlos-restore-verify.timer", |
| "carlos-stage.path", |
diff --git a/internal/host/probes.go b/internal/host/probes.go| index 6a64ce1c4..5264137f3 100644 |
| --- a/internal/host/probes.go |
| +++ b/internal/host/probes.go |
| @@ -62,6 +62,25 @@ func configPathActive() *bool { |
| return parseIsActive(string(out)) |
| } |
| +// restartPathUnit is the unit whose liveness IS "a member's `carlos restart` |
| +// reaches this box's units in seconds" (issue #113): the path unit watching |
| +// /data/restart-requested, which the agent touches and which starts |
| +// carlos-restart.service. Installed and armed by bootstrap alongside the other |
| +// two (see enabledUnits). |
| +const restartPathUnit = "carlos-restart.path" |
| + |
| +// restartPathActive asks the same question configPathActive asks, about a unit |
| +// with the same silent failure mode — and one worse consequence. An unarmed |
| +// config trigger costs an env var an hour; an unarmed restart trigger means |
| +// someone typed `carlos restart` at a wedged instance, was told "requested", |
| +// and will stand there watching a process that will not cycle until the hourly |
| +// pass comes round. Nothing inside the agent can see the difference: it writes |
| +// its marker either way. |
| +func restartPathActive() *bool { |
| + out, _ := exec.Command("systemctl", "is-active", restartPathUnit).Output() |
| + return parseIsActive(string(out)) |
| +} |
| + |
| // parseIsActive maps systemd's is-active vocabulary onto the tri-state the |
| // status page publishes. "unknown" — systemd's answer when it has no such unit |
| // loaded — is a genuine false: a box with no adoption timer adopts nothing. |
diff --git a/internal/host/restart.go b/internal/host/restart.go| new file mode 100644 |
| index 000000000..7ebe61726 |
| --- /dev/null |
| +++ b/internal/host/restart.go |
| @@ -0,0 +1,650 @@ |
| +// The box half of `carlos restart` (issue #113): when an app's |
| +// restart-generation object (release.RestartGenKey) MOVES, cycle that app's |
| +// live processes on this box. Pull-based like everything else here — no push |
| +// channel, no box access, no version or config change involved. |
| +// |
| +// IT RIDES THE SERIAL, and that is the load-bearing design decision. The |
| +// console door bumps control/serial after touching the object, exactly as |
| +// every other console write does, and the agent's existing 2s serial poll |
| +// (fastTick) already turns a bump into the full convergence pass set. The |
| +// restart check is one more leg of THAT — so a box with nothing happening |
| +// pays its one serial GET per tick and NOT ONE READ MORE. An earlier revision |
| +// polled every app's restart generation on its own 3s timer; measured, that |
| +// was ~575k needless GETs per box per day to notice an event that arrives a |
| +// handful of times a month. A verb that costs the fleet a permanent poll storm |
| +// to exist is not a verb worth having. |
| +// |
| +// Direct-bucket touches that never bump the serial (a script writing the |
| +// object straight into the bucket) converge on the hourly root pass instead — |
| +// the same floor every other direct-bucket write already relies on. |
| +// |
| +// The work is split across two processes, for the reason config fast |
| +// convergence is split the same way: |
| +// |
| +// - the AGENT (carlos-edge.service, User=carlos, ProtectSystem=strict) owns |
| +// the EXEC-backed instances outright, so its half is the etag comparison, |
| +// a bounce of its own children, and one marker file for the rest. |
| +// systemctl cannot reach a child of another process, so a root-only |
| +// dispatch would leave every warm exec instance — the commonest shape on |
| +// the box, and the one a wedged instance usually is — serving on, |
| +// unrestarted. |
| +// - the ROOT pass (carlos adopt, out of carlos-restart.service via |
| +// carlos-restart.path, or the hourly timer) is the only one that can |
| +// restart a systemd unit — the carlos-app@ tenants and the bespoke units |
| +// addr-backed routes name (issue #110's consoles). Its half is the |
| +// comparison against a persisted, per-app high-water mark and the |
| +// dispatch. |
| +// |
| +// SEED-DON'T-REPLAY governs both halves, PER APP. An app this box has not |
| +// recorded yet is recorded and NOT restarted, whether its object exists or |
| +// not; only movement away from a value this box has already written counts. |
| +// The absent case gets its own recorded sentinel precisely so that an app's |
| +// first ever request (absent -> present) is still movement rather than a first |
| +// sight — that distinction is the difference between the verb working and the |
| +// verb cycling a box's whole route table the day a marks file goes missing. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "syscall" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// restartApps lists the distinct (account, app) pairs with at least one |
| +// restartable route on this box — the apps whose restart generation this box |
| +// reads when a serial bump (or an hourly pass) gives it a reason to. |
| +// |
| +// The membership predicate is release.Adoptable, deliberately the SAME one the |
| +// adopt pass moves and the fast sweep counts: a route a pass would not adopt |
| +// is not one whose process this feature has any business cycling, and a second |
| +// copy of that judgement here is exactly the drift that would let the two |
| +// disagree. It admits addr-backed routes that name their own unit (issue |
| +// #110's consoles) — which is half of why #113 exists — because Adoptable |
| +// filters on lifecycle and app, never on backing. |
| +func restartApps(routes []registry.Route) [][2]string { |
| + seen := map[[2]string]bool{} |
| + var out [][2]string |
| + for _, rt := range routes { |
| + if !release.Adoptable(rt) { |
| + continue |
| + } |
| + k := [2]string{release.RouteAccount(rt), rt.App} |
| + if seen[k] { |
| + continue |
| + } |
| + seen[k] = true |
| + out = append(out, k) |
| + } |
| + return out |
| +} |
| + |
| +// restartGenPass is the agent's comparison, split out for tests: read the |
| +// restart-generation etag of every app this box serves and report WHICH of |
| +// them moved. Same semantics — and the same reasons — as genPollPass: |
| +// |
| +// - First sight of an app records silently and asks for nothing. A box |
| +// coming up must not restart everything it serves because it has never |
| +// seen these etags before; that is the seed-don't-replay rule, and it is |
| +// the whole safety property of this feature. restartSeed runs one such |
| +// silent pass at arming, so the FIRST request after a boot is acted on |
| +// rather than swallowed as the baseline. |
| +// - An unchanged etag does nothing. |
| +// - Absent that stays absent does nothing, FOREVER: an app nobody has ever |
| +// restarted this way has no object and never will until someone asks. |
| +// That is the ordinary state of almost every app, not a fault. |
| +// - Any movement asks: the etag changed, or absent -> present (an app's |
| +// first ever restart request). present -> absent should not happen |
| +// (restart generations are only created and overwritten, never deleted), |
| +// but it counts as movement too — one extra root pass is cheap, and |
| +// pretending we did not see it would be a lie about the only thing this |
| +// function reports. |
| +// - The moved apps come back as a LIST, but the root request is one marker |
| +// touch however long that list is: the root pass re-reads every app's etag |
| +// itself and dispatches per app, so asking once per moved app would only |
| +// ask the same pass to run several times. The list is for the caller's own |
| +// half — which of ITS exec children to bounce. |
| +// - A read error that is not "not found" leaves that app's cache entry |
| +// exactly as it was and keeps evaluating the rest — the errors come back |
| +// for the caller to aggregate into ONE log line per pass. Clearing the |
| +// entry would turn a recovered blip into a bogus first sight or a bogus |
| +// restart. |
| +// - Apps that vanish from the route set lose their cache entries: a re-added |
| +// app is a first sight again, which is right, because a route that has |
| +// just been (re-)minted is starting its process fresh anyway. |
| +// |
| +// The cache is mutated in place and lives for the life of the agent process |
| +// (fastConvergeLoop owns it, restartSeed fills it), which is the whole point: |
| +// there is nowhere else to remember what "changed" means between two serial |
| +// moves that may be days apart. |
| +func restartGenPass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (moved [][2]string, readErrs []error) { |
| + apps := restartApps(routes) |
| + live := make(map[[2]string]bool, len(apps)) |
| + for _, k := range apps { |
| + live[k] = true |
| + _, etag, err := store.Get(ctx, release.RestartGenKey(k[0], k[1])) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = genAbsent |
| + case err != nil: |
| + readErrs = append(readErrs, fmt.Errorf("%s/%s: %w", k[0], k[1], err)) |
| + continue // cache entry untouched: this app is unknown this tick, not changed |
| + } |
| + prev, seen := cache[k] |
| + cache[k] = etag |
| + if seen && prev != etag { |
| + moved = append(moved, k) |
| + } |
| + } |
| + for k := range cache { |
| + if !live[k] { |
| + delete(cache, k) |
| + } |
| + } |
| + return moved, readErrs |
| +} |
| + |
| +// bounceExecRoutes cycles the agent's OWN children for the apps whose restart |
| +// generation just moved, and returns how many it actually stopped. This is the |
| +// half no root pass can perform: an exec instance is a child of THIS process, |
| +// and systemctl has no name for it. |
| +// |
| +// The route filter is the complement of what rootRestartRoute's systemctl arms |
| +// can reach — no named unit, no unit backing — so the two halves never cycle |
| +// the same process twice. |
| +// |
| +// Coverage is NOT total, and the gap is worth naming: a route that is |
| +// activator-backed (Backing "", an exec child) but ALSO carries a Unit stamp |
| +// is skipped here (it names a unit) while the root half try-restarts that |
| +// named unit, which is not the process the activator is running. Such a route |
| +// is a misconfiguration — `carlos route -unit` is for addr-backed tenants |
| +// whose process an operator owns — but nothing in this file repairs it, and |
| +// its exec child is cycled by NEITHER half until somebody clears the stamp. |
| +// |
| +// HIBERNATE-flagged routes are included, and that is not a contradiction of |
| +// "never wake a sleeper": Bounce provably cannot start anything (it is a no-op |
| +// unless the instance is AWAKE), so a sleeping instance is untouched and picks |
| +// its next start up naturally, while a WARM hibernator — which is what a |
| +// wedged instance usually is — gets exactly the cycle that was asked for. This |
| +// is release.RestartHibernating's argument, in the one process that can act on |
| +// it. |
| +// |
| +// A nil bounce (no activator armed on this box) is a working no-op: there are |
| +// no children to cycle, and the unit half still runs. |
| +func bounceExecRoutes(routes []registry.Route, moved [][2]string, bounce func(host string) bool) int { |
| + if bounce == nil || len(moved) == 0 { |
| + return 0 |
| + } |
| + want := make(map[[2]string]bool, len(moved)) |
| + for _, k := range moved { |
| + want[k] = true |
| + } |
| + n := 0 |
| + for _, rt := range routes { |
| + if !release.Adoptable(rt) || rt.Unit != "" || rt.Backing == registry.BackingUnit { |
| + continue |
| + } |
| + if !want[[2]string{release.RouteAccount(rt), rt.App}] { |
| + continue |
| + } |
| + if bounce(rt.Host) { |
| + n++ |
| + log.Printf("agent: restart requested: bounced exec instance %s", rt.Host) |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// restartRequestMarker is the file the agent's restart leg touches to ask a |
| +// root pass to run now: carlos-restart.path watches it (PathChanged) and starts |
| +// carlos-restart.service (`carlos adopt -restart-only`), which owns the half |
| +// this unprivileged process cannot do — restarting units — and only that half. |
| +// |
| +// A sibling of configSyncMarker rather than a reuse of it, because the two |
| +// relays land on different services: config's target is `carlos adopt |
| +// -config-only`, which renders /etc/carlos/apps and restarts only the routes |
| +// whose env file actually changed. A restart request changes no file, so that |
| +// pass would find nothing to do and cycle nothing at all. |
| +// |
| +// It lives in the data dir, deliberately outside the *.config.env namespace |
| +// reapConfigEnv sweeps — the reaper matches that suffix alone and must go on |
| +// ignoring this. |
| +const restartRequestMarker = "restart-requested" |
| + |
| +// requestRootRestart touches the marker. The content is a timestamp only so an |
| +// operator reading /data can see when the last request went out; systemd |
| +// watches the path, not the bytes. Errors are the caller's to log and swallow: |
| +// a request that cannot be written costs latency, not correctness, because |
| +// carlos-adopt.timer's hourly pass runs the same comparison underneath it. |
| +func requestRootRestart(dd string) error { |
| + return os.WriteFile(filepath.Join(dd, restartRequestMarker), |
| + []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644) |
| +} |
| + |
| +// restartMarksFile is the ROOT pass's memory: the last restart generation it |
| +// has already acted on, per app. It lives in the data dir beside the bake |
| +// clock, in the same JSON + temp-file-rename shape and for the same |
| +// cross-uid reason (see restartMarks.save). |
| +const restartMarksFile = "restart-marks.json" |
| + |
| +func restartMarksPath(dd string) string { return filepath.Join(dd, restartMarksFile) } |
| + |
| +// restartMarks is that memory. Apps is keyed "<account>|<app>" — "|" is |
| +// outside every character class a sqid or an app name allows, the same |
| +// separator argument internal/release's bake clock makes for its own keys. |
| +// |
| +// A value is either the etag this box last acted on OR restartAbsent, the |
| +// recorded "this app has no restart generation and I have looked". On disk: |
| +// |
| +// { |
| +// "schema": 1, |
| +// "apps": { |
| +// "bab|keymail": "\"9f86d0…\"", |
| +// "bab|titogo": "\u0000absent" |
| +// } |
| +// } |
| +// |
| +// The absent sentinel is what makes per-app seed-don't-replay possible. Without |
| +// it "no entry" has to mean both "never looked" and "looked, nothing there", |
| +// and one of those two has to be wrong: read as movement, a box gaining a new |
| +// route for a previously-restarted app cycles it for a request served weeks |
| +// ago (and a box whose registry lands after its marks file cycles EVERY route |
| +// at once); read as a first sight, an app's first ever request is silently |
| +// dropped. Recording the absence tells them apart — a later object sighting is |
| +// movement from a value this box wrote, which is exactly what it is. |
| +// |
| +// Entries are never pruned when a route leaves the box. A vanished app's mark |
| +// costs one map entry; DROPPING it would make the app's return look like a |
| +// brand-new object and cycle it once for a request that was already served. |
| +type restartMarks struct { |
| + Schema int `json:"schema"` // 1 |
| + Apps map[string]string `json:"apps"` |
| +} |
| + |
| +// restartAbsent is the marks value for an app this box has SEEN and found to |
| +// have no restart-generation object at all. It is deliberately the SAME value |
| +// the in-memory sweep uses (genAbsent), so the agent's memory and the root |
| +// pass's memory cannot drift apart on what "absent" is, and it is safe for the |
| +// same reason: no store produces an etag containing a NUL byte (S3's are |
| +// hex/quoted, DirStore's is hex). JSON renders it "\u0000absent". |
| +const restartAbsent = genAbsent |
| + |
| +func restartMarkKey(acct, app string) string { return acct + "|" + app } |
| + |
| +// lockRestartMarks takes an exclusive flock for the whole of one restartPass. |
| +// Three root passes can run this — carlos-restart.service (the relay), |
| +// carlos-stage.service and carlos-adopt.service — and systemd will happily |
| +// start them at the same second. Unserialized, two passes read the same marks, |
| +// both dispatch (one request, two try-restarts, on a verb whose entire job is |
| +// stopping processes) and then both write, so whichever finishes last silently |
| +// reverts the other's advance and the NEXT pass restarts everything a third |
| +// time. |
| +// |
| +// The lock lives in a sibling <marks>.lock rather than on the marks file |
| +// itself, because the marks file is replaced by rename: a flock is held on an |
| +// inode, and the winner's rename would leave the loser holding a lock on a file |
| +// that is no longer the marks file. |
| +// |
| +// Opening it is ALSO the writability test the dispatch is gated on (see |
| +// restartPass): it is created in the same directory, with the same permissions |
| +// the marks save needs, so a full or read-only /data fails here — before |
| +// anything is cycled — instead of after, which is the difference between one |
| +// logged refusal and an hourly restart of every app on the box forever. |
| +// |
| +// syscall.Flock is available on both linux and darwin, which is the whole |
| +// build matrix (CI cross-compiles darwin because the CLI runs on Macs). |
| +func lockRestartMarks(path string) (unlock func(), err error) { |
| + f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o644) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { |
| + f.Close() |
| + return nil, err |
| + } |
| + return func() { |
| + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) |
| + f.Close() |
| + }, nil |
| +} |
| + |
| +// loadRestartMarks reads the marks file. `known` is the seed-don't-replay |
| +// switch and the only subtle thing here: it is FALSE when the file is missing, |
| +// unreadable, or does not parse — three different accidents with one correct |
| +// response, which is to record what the bucket currently says and restart |
| +// NOTHING this pass. |
| +// |
| +// That direction is the opposite of the bake clock's ("an unreadable clock |
| +// fails toward baking"), and deliberately so: baking is the cautious act there, |
| +// while restarting is the DISRUPTIVE act here. The cost of failing this way is |
| +// that a box whose marks file was just wiped can drop ONE restart request — |
| +// logged, and re-runnable by the person who typed it. The cost of failing the |
| +// other way is a box that cycles every app it serves the moment its state file |
| +// goes missing. |
| +func loadRestartMarks(path string) (marks *restartMarks, known bool) { |
| + m := &restartMarks{Schema: 1, Apps: map[string]string{}} |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("adopt: restart marks %s unreadable (%v) — re-baselining, no restarts this pass", path, err) |
| + } |
| + return m, false |
| + } |
| + if err := json.Unmarshal(raw, m); err != nil { |
| + log.Printf("adopt: restart marks %s do not parse (%v) — re-baselining, no restarts this pass", path, err) |
| + return &restartMarks{Schema: 1, Apps: map[string]string{}}, false |
| + } |
| + if m.Apps == nil { |
| + m.Apps = map[string]string{} |
| + } |
| + m.Schema = 1 |
| + return m, true |
| +} |
| + |
| +// save persists the marks via temp-file-in-the-same-dir + rename, the |
| +// co-written-file pattern release.FirstSeen.Save documents at length: /data is |
| +// written by BOTH uids on this box (carlos for the agent's seed below, root |
| +// for this pass), and an in-place O_TRUNC write needs write permission on the |
| +// existing file, so whichever uid created it would lock the other out forever. |
| +// rename(2) only needs write permission on the directory, which both have. The |
| +// temp name is minted fresh per attempt so a crash between write and rename |
| +// can never leave cross-uid residue for the next save to trip over, and the |
| +// 0600 CreateTemp mints is widened to 0644 BEFORE the rename so the other uid |
| +// can still read it. |
| +func (m *restartMarks) save(path string) error { |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + raw = append(raw, '\n') |
| + f, err := os.CreateTemp(filepath.Dir(path), "restart-marks-*.json") |
| + if err != nil { |
| + return err |
| + } |
| + tmp := f.Name() |
| + _, writeErr := f.Write(raw) |
| + closeErr := f.Close() |
| + if writeErr != nil { |
| + os.Remove(tmp) |
| + return writeErr |
| + } |
| + if closeErr != nil { |
| + os.Remove(tmp) |
| + return closeErr |
| + } |
| + if err := os.Chmod(tmp, 0o644); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) // don't leave a stray temp file behind |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// seedRestartMarks writes the baseline the ROOT pass compares against, ONCE, |
| +// from the agent's arming sweep — create-only (O_EXCL), so it can never |
| +// overwrite a mark the root pass has already advanced. |
| +// |
| +// It exists to close a real hole in the handover between the two halves. The |
| +// agent seeds its own baseline as it arms; the root pass would otherwise not |
| +// seed until it next ran, and a restart requested inside that window would |
| +// arrive at a pass with no baseline — which, by the rule above, records and |
| +// restarts nothing. The request would be silently dropped, having told its |
| +// caller "requested". Creating the file here shrinks that window to the moment |
| +// the agent starts, where the box has just restarted everything anyway. |
| +// |
| +// An EMPTY cache writes nothing at all. A sweep that saw no adoptable routes |
| +// is not a baseline — most often it is a box whose registry has not landed yet |
| +// (a fresh machine, or one restoring /data from litestream) — and a file |
| +// claiming "this box knows about no apps" is precisely the file that makes the |
| +// root pass treat every route it later gains as movement, and cycle the lot in |
| +// one pass. |
| +// |
| +// Absent objects ARE recorded, with restartAbsent: "looked, nothing there" is |
| +// the fact that makes an app's first ever request movement rather than a first |
| +// sight. |
| +func seedRestartMarks(path string, cache map[[2]string]string) error { |
| + if len(cache) == 0 { |
| + return nil // nothing was seen; nothing may be claimed |
| + } |
| + if _, err := os.Stat(path); err == nil { |
| + return nil // the root pass (or an earlier agent) already has a baseline |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + m := &restartMarks{Schema: 1, Apps: map[string]string{}} |
| + for k, etag := range cache { |
| + m.Apps[restartMarkKey(k[0], k[1])] = etag |
| + } |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + raw = append(raw, '\n') |
| + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| + if err != nil { |
| + if errors.Is(err, os.ErrExist) { |
| + return nil // lost the race to the root pass; its mark is the better one |
| + } |
| + return err |
| + } |
| + _, writeErr := f.Write(raw) |
| + if err := f.Close(); err != nil && writeErr == nil { |
| + writeErr = err |
| + } |
| + return writeErr |
| +} |
| + |
| +// restartPass is the ROOT half: for every app on this box whose restart |
| +// generation has moved since the mark this box recorded, cycle that app's |
| +// routes. Called from RunAdopt — on the marker-triggered `-restart-only` run |
| +// (seconds after the request) and on the ordinary pass (the hourly floor |
| +// underneath the relay, which can drop an event that lands while the service |
| +// is mid-run). |
| +// |
| +// The rules, each a deliberate choice: |
| +// |
| +// - ZERO adoptable routes ends the pass before anything is read or written. |
| +// A box whose registry has not landed yet (fresh, or restoring /data) must |
| +// not be able to record "I know about no apps" — that file is what turns |
| +// every route the box later gains into movement, and cycles the whole |
| +// table in one pass. |
| +// - An app this box has NOT recorded yet is recorded and NOT cycled, |
| +// whether its object exists or not. Per-app seed-don't-replay: the marks |
| +// file is this box's memory of what it has acted on, and a request it |
| +// never saw is not one it owes anybody. |
| +// - An app with NO restart generation records restartAbsent — the sentinel |
| +// that makes its FIRST ever request (absent -> present) movement rather |
| +// than another first sight. Absent is legal forever; unrecorded absence is |
| +// what was not. |
| +// - An unknown baseline (see loadRestartMarks) records every app and |
| +// dispatches nothing — and if any app could not be READ, records nothing |
| +// at all: a baseline missing the apps a transient 503 hid is a baseline |
| +// that turns those apps into movement on the next pass. A dropped request |
| +// is recoverable (run the verb again); a restart nobody asked for is not. |
| +// - HIBERNATING routes are skipped outright and not marked as a failure. |
| +// Restarting a process that is not running is meaningless, waking it to |
| +// restart it is worse (it would defeat hibernation for no gain), and it |
| +// will exec fresh on its next wake regardless — which is the restart. A |
| +// WARM hibernating exec instance is not left serving, though: the agent's |
| +// own bounce (bounceExecRoutes) reaches it, in the one process that can. |
| +// - A dispatch FAILURE still advances the mark, and logs. A restart request |
| +// is a point-in-time event, not a desired state: a named unit that exits |
| +// non-zero (a wrong unit name, a service that will not come back) would |
| +// otherwise be retried on every pass, forever, for a request somebody made |
| +// once. The journal line is the report; `carlos restart` again is the |
| +// retry. |
| +// - The mark is written only AFTER the dispatch, so a pass that dies |
| +// mid-cycle retries rather than swallowing the request. |
| +// - The whole pass holds an exclusive lock (lockRestartMarks), because three |
| +// root units can run it at once; failing to TAKE that lock dispatches |
| +// nothing, since the lock file's creation is also the proof that the marks |
| +// can be saved afterwards. A pass that cycles processes and then cannot |
| +// record what it did would cycle them again every hour, forever. |
| +// |
| +// Holds, bake windows and the release ladder take no part in any of this: |
| +// nothing about which version runs changes here, so there is nothing for them |
| +// to gate. |
| +func restartPass(ctx context.Context, routes []registry.Route, store release.Store, marksPath string, restart func(host string) error) { |
| + apps := restartApps(routes) |
| + if len(apps) == 0 { |
| + return // nothing to converge, and nothing this box may claim to know |
| + } |
| + unlock, err := lockRestartMarks(marksPath) |
| + if err != nil { |
| + // No lock means either a concurrent pass we cannot serialize against or |
| + // a /data we cannot write. Both make dispatching the wrong move: the |
| + // first double-restarts, the second restarts hourly and forever because |
| + // the mark can never advance. |
| + log.Printf("adopt: restart pass: cannot lock %s.lock (%v) — nothing cycled this pass", marksPath, err) |
| + return |
| + } |
| + defer unlock() |
| + |
| + marks, known := loadRestartMarks(marksPath) |
| + changed := false |
| + readFailed := false |
| + for _, k := range apps { |
| + _, etag, err := store.Get(ctx, release.RestartGenKey(k[0], k[1])) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = restartAbsent |
| + case err != nil: |
| + log.Printf("adopt: restart generation %s/%s: %v (skipped this pass)", k[0], k[1], err) |
| + readFailed = true |
| + continue |
| + } |
| + key := restartMarkKey(k[0], k[1]) |
| + prev, seen := marks.Apps[key] |
| + if !known || !seen { |
| + // SEED. Either this box's whole memory is untrustworthy, or this is |
| + // the first time it has looked at this app — a route it has just |
| + // gained, whose process is starting fresh anyway. Record, cycle |
| + // nothing. |
| + if !seen || prev != etag { |
| + marks.Apps[key] = etag |
| + changed = true |
| + } |
| + continue |
| + } |
| + if prev == etag { |
| + continue |
| + } |
| + dispatched := 0 |
| + for _, rt := range routes { |
| + if !release.Adoptable(rt) || rt.App != k[1] || release.RouteAccount(rt) != k[0] { |
| + continue |
| + } |
| + if rt.Hibernate { |
| + log.Printf("adopt: restart requested for %s/%s: %s is hibernating — left asleep (it execs fresh on its next wake)", |
| + k[0], k[1], rt.Host) |
| + continue |
| + } |
| + if err := restart(rt.Host); err != nil { |
| + log.Printf("adopt: restart requested for %s/%s: %s did not cycle: %v", k[0], k[1], rt.Host, err) |
| + continue |
| + } |
| + dispatched++ |
| + } |
| + // One line per APP, not per route, and it says DISPATCHED rather than |
| + // cycled: an exec-backed route reaching rootRestartRoute lands on a |
| + // try-restart of a carlos-app@ unit that does not exist — a deliberate |
| + // no-op (the agent bounced that child itself) which this pass cannot |
| + // tell apart from a real one. Claiming "cycled" for it would be the |
| + // kind of lie issue #110 was made of. |
| + log.Printf("adopt: restart requested for %s/%s: dispatched to %d route(s)", k[0], k[1], dispatched) |
| + marks.Apps[key] = etag |
| + changed = true |
| + } |
| + if !known && readFailed { |
| + // A baseline that omits the apps a blip hid is worse than no baseline: |
| + // this pass would write "known", and the next one would read those |
| + // missing apps as movement and cycle them. Leave the file as it is and |
| + // re-baseline on a pass that can read everything. |
| + log.Printf("adopt: restart pass: baselining deferred — some apps could not be read this pass") |
| + return |
| + } |
| + // A first, unknown baseline is written even when nothing changed: that |
| + // write is what makes the NEXT pass a known one, and until it lands every |
| + // pass would re-baseline and drop whatever it was asked to do. |
| + if changed || !known { |
| + if err := marks.save(marksPath); err != nil { |
| + log.Printf("adopt: save restart marks %s: %v (the next pass may repeat a restart)", marksPath, err) |
| + } |
| + } |
| +} |
| + |
| +// restartRequestLeg is the agent's half of one serial move: compare each app's |
| +// restart generation against the in-memory cache, bounce this process's own |
| +// exec children for the apps that moved, and touch the marker that asks a root |
| +// pass to cycle everything unit-shaped. Reports whether it asked. |
| +// |
| +// It runs ONLY when the serial moved (fastTick), which is what makes the |
| +// feature free at rest: the console bumps the serial after touching the |
| +// object, so every request is announced, and an idle box reads no restart |
| +// generation at all. There is no loop of its own and there must not be one — |
| +// see this file's package comment for what the 3s version of this cost. |
| +// |
| +// The bounce goes first, and not only for tidiness: it is in-process and |
| +// instant, while the marker write can block on a busy /data, and the children |
| +// bounced here are the ones cycled here or nowhere. |
| +func restartRequestLeg(ctx context.Context, routes []registry.Route, store release.Store, dd string, |
| + bounce func(host string) bool, cache map[[2]string]string) bool { |
| + moved, readErrs := restartGenPass(ctx, routes, store, cache) |
| + if len(readErrs) > 0 { |
| + // ONE line per pass, never one per app: the count carries the scale, |
| + // the first error the cause — they are the same cause in practice. |
| + log.Printf("agent: restart check: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| + } |
| + if len(moved) == 0 { |
| + return false |
| + } |
| + bounceExecRoutes(routes, moved, bounce) |
| + if err := requestRootRestart(dd); err != nil { |
| + log.Printf("agent: request root restart pass: %v", err) |
| + return false |
| + } |
| + log.Printf("agent: restart requested for %d app(s) on this box, asked for a root restart pass", len(moved)) |
| + return true |
| +} |
| + |
| +// restartSeed is the ONE sweep this agent makes on its own initiative: at |
| +// arming, read each app's restart generation once, silently, so the cache the |
| +// leg compares against exists before the first serial move — and hand the same |
| +// sightings to seedRestartMarks so the root pass has a baseline too. |
| +// |
| +// Without it the first request after a boot is the sweep that establishes the |
| +// baseline, and is therefore the one request that does nothing: the person who |
| +// typed `carlos restart` would have to type it twice, having been told |
| +// "requested" the first time. One read per app per agent start is the whole |
| +// cost, and it is paid where a box has just restarted everything anyway. |
| +// |
| +// A read error leaves that app out of the cache (its next sighting is a first |
| +// sight — safe) and blocks the marks seed entirely, for the reason |
| +// seedRestartMarks and restartPass both give: a baseline with holes in it is |
| +// the thing that manufactures restarts. |
| +func restartSeed(ctx context.Context, reg *registry.Registry, store release.Store, dd string, cache map[[2]string]string) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("agent: restart baseline: list routes: %v (the first request may need a second try)", err) |
| + return |
| + } |
| + _, readErrs := restartGenPass(ctx, routes, store, cache) |
| + if len(readErrs) > 0 { |
| + log.Printf("agent: restart baseline: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| + return |
| + } |
| + if err := seedRestartMarks(restartMarksPath(dd), cache); err != nil { |
| + log.Printf("agent: seed restart marks: %v", err) |
| + } |
| +} |
diff --git a/internal/host/restart_test.go b/internal/host/restart_test.go| new file mode 100644 |
| index 000000000..bf59e7a98 |
| --- /dev/null |
| +++ b/internal/host/restart_test.go |
| @@ -0,0 +1,706 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// restartEtag reads an app's restart-generation etag straight from the store, |
| +// so a test asserts on what the bucket actually reports rather than a value it |
| +// made up. |
| +func restartEtag(t *testing.T, ctx context.Context, s release.Store, acct, app string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(ctx, release.RestartGenKey(acct, app)) |
| + if err != nil { |
| + t.Fatalf("read restart generation %s/%s: %v", acct, app, err) |
| + } |
| + return etag |
| +} |
| + |
| +// askRestart is a member's `carlos restart` landing in the bucket: the one |
| +// write the whole feature turns on. |
| +func askRestart(t *testing.T, ctx context.Context, s release.Store, acct, app string) { |
| + t.Helper() |
| + if err := release.TouchRestartGen(ctx, s, acct, app); err != nil { |
| + t.Fatalf("TouchRestartGen %s/%s: %v", acct, app, err) |
| + } |
| +} |
| + |
| +// namedUnitRoute is the addr-backed, operator-named-unit shape issue #110 |
| +// added and #113's second case is about: a console. Only the ROOT pass can |
| +// cycle it. |
| +func namedUnitRoute(host, acct, app, unit string) registry.Route { |
| + rt := execRoute(host, acct, app) |
| + rt.Socket = "" |
| + rt.Addr = "127.0.0.1:9000" |
| + rt.Unit = unit |
| + return rt |
| +} |
| + |
| +// recordingRestart is the root pass's dispatch seam: every host it was asked |
| +// to cycle, in order, plus an optional per-host failure. |
| +type recordingRestart struct { |
| + hosts []string |
| + fail map[string]error |
| +} |
| + |
| +func (r *recordingRestart) fn(host string) error { |
| + r.hosts = append(r.hosts, host) |
| + if err, ok := r.fail[host]; ok { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// ---- the agent's sweep ------------------------------------------------------- |
| + |
| +func TestRestartGenPassFirstSightIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") // requested before this box ever looked |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + |
| + moved, errs := restartGenPass(ctx, routes, s, cache) |
| + if len(moved) != 0 { |
| + t.Fatalf("first sight moved %v — a box that has just started must not cycle everything it serves", moved) |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if got, want := cache[[2]string{"bab", "eleven"}], restartEtag(t, ctx, s, "bab", "eleven"); got != want { |
| + t.Fatalf("cache etag = %q, want the store's %q", got, want) |
| + } |
| +} |
| + |
| +func TestRestartGenPassMovementAsksOnce(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "eleven"), // same app, two routes |
| + } |
| + cache := map[[2]string]string{} |
| + restartGenPass(ctx, routes, s, cache) // baseline |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + moved, errs := restartGenPass(ctx, routes, s, cache) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(moved) != 1 || moved[0] != [2]string{"bab", "eleven"} { |
| + t.Fatalf("moved = %v, want exactly one app entry (one request, one ask)", moved) |
| + } |
| + if moved, _ := restartGenPass(ctx, routes, s, cache); len(moved) != 0 { |
| + t.Fatalf("the pass after a request moved %v — a stale cache would ask forever", moved) |
| + } |
| +} |
| + |
| +func TestRestartGenPassAbsentStaysSilentAndFirstRequestMoves(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + |
| + // An app nobody has ever restarted: absent, legal, forever. |
| + for i := range 2 { |
| + moved, errs := restartGenPass(ctx, routes, s, cache) |
| + if len(moved) != 0 { |
| + t.Fatalf("pass %d: an app with no restart generation moved %v", i, moved) |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("pass %d: absent is not a read error: %v", i, errs) |
| + } |
| + } |
| + if got := cache[[2]string{"bab", "eleven"}]; got != genAbsent { |
| + t.Fatalf("cache = %q, want the known-absent sentinel %q", got, genAbsent) |
| + } |
| + |
| + // Its FIRST ever request: absent -> present is movement, or the very first |
| + // `carlos restart` for an app would be the one that never works. |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + moved, _ := restartGenPass(ctx, routes, s, cache) |
| + if len(moved) != 1 { |
| + t.Fatalf("absent -> present moved %v, want the app", moved) |
| + } |
| +} |
| + |
| +func TestRestartGenPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + routes := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "titogo"), |
| + } |
| + cache := map[[2]string]string{} |
| + restartGenPass(ctx, routes, s, cache) |
| + before := cache[[2]string{"bab", "eleven"}] |
| + |
| + askRestart(t, ctx, s, "bab", "titogo") // a real request on the app that CAN be read |
| + blip := &failingKeyStore{Store: s, failKey: release.RestartGenKey("bab", "eleven"), err: errors.New("503 slow down")} |
| + moved, errs := restartGenPass(ctx, routes, blip, cache) |
| + if len(errs) != 1 { |
| + t.Fatalf("read errors = %v, want exactly one", errs) |
| + } |
| + if len(moved) != 1 || moved[0] != [2]string{"bab", "titogo"} { |
| + t.Fatalf("moved = %v, want only the readable app — one bad object must not stop the pass", moved) |
| + } |
| + if got := cache[[2]string{"bab", "eleven"}]; got != before { |
| + t.Fatalf("cache for the unreadable app = %q, want it untouched at %q (a cleared entry is a bogus first sight)", got, before) |
| + } |
| +} |
| + |
| +func TestRestartGenPassDropsVanishedApps(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + both := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "titogo"), |
| + } |
| + cache := map[[2]string]string{} |
| + restartGenPass(ctx, both, s, cache) |
| + |
| + restartGenPass(ctx, both[:1], s, cache) // titogo's route is gone |
| + if _, ok := cache[[2]string{"bab", "titogo"}]; ok { |
| + t.Fatal("a route that left the box kept its cache entry") |
| + } |
| + moved, _ := restartGenPass(ctx, both, s, cache) // and comes back |
| + if len(moved) != 0 { |
| + t.Fatalf("a re-added app moved %v, want a silent first sight", moved) |
| + } |
| +} |
| + |
| +// TestRestartGenPassIgnoresNonAdoptableRoutes: the membership predicate is |
| +// release.Adoptable, the same one the adopt pass moves. A platform alias |
| +// shares its canonical route's process, and a mid-provision route must not be |
| +// disturbed — neither is an app this box should be reading a restart signal |
| +// for. |
| +func TestRestartGenPassIgnoresNonAdoptableRoutes(t *testing.T) { |
| + ctx, s := genKit(t) |
| + alias := execRoute("alias.oncarlos.com", "bab", "eleven") |
| + alias.Platform = true |
| + pending := execRoute("new.example.com", "bab", "titogo") |
| + pending.Status = "creating" |
| + |
| + cache := map[[2]string]string{} |
| + restartGenPass(ctx, []registry.Route{alias, pending}, s, cache) |
| + if len(cache) != 0 { |
| + t.Fatalf("cache = %v, want empty (neither route is adoptable)", cache) |
| + } |
| +} |
| + |
| +// ---- the agent's own children ------------------------------------------------ |
| + |
| +func TestBounceExecRoutesCyclesOnlyWhatRootCannot(t *testing.T) { |
| + moved := [][2]string{{"bab", "eleven"}} |
| + routes := []registry.Route{ |
| + execRoute("exec.example.com", "bab", "eleven"), // the agent's child |
| + unitRoute("unit.example.com", "bab", "eleven"), // carlos-app@ — root's |
| + namedUnitRoute("console.example.com", "bab", "eleven", "console.service"), // #110 — root's |
| + execRoute("other.example.com", "bab", "titogo"), // a different app |
| + } |
| + var bounced []string |
| + n := bounceExecRoutes(routes, moved, func(h string) bool { |
| + bounced = append(bounced, h) |
| + return true |
| + }) |
| + if n != 1 || !slices.Equal(bounced, []string{"exec.example.com"}) { |
| + t.Fatalf("bounced %v (n=%d), want only the exec-backed route of the moved app", bounced, n) |
| + } |
| +} |
| + |
| +// TestBounceExecRoutesReachesAWarmHibernator: a hibernate-flagged route is |
| +// included on purpose. Bounce cannot start anything, so a SLEEPING instance is |
| +// untouched — but a warm one is exactly the wedged instance `carlos restart` |
| +// exists for, and leaving it serving would be the feature failing at its own |
| +// headline case. |
| +func TestBounceExecRoutesReachesAWarmHibernator(t *testing.T) { |
| + rt := execRoute("warm.example.com", "bab", "eleven") |
| + rt.Hibernate = true |
| + var bounced []string |
| + // The activator's own contract: false when not awake, true when it stopped |
| + // something. Here it is awake. |
| + bounceExecRoutes([]registry.Route{rt}, [][2]string{{"bab", "eleven"}}, func(h string) bool { |
| + bounced = append(bounced, h) |
| + return true |
| + }) |
| + if !slices.Equal(bounced, []string{"warm.example.com"}) { |
| + t.Fatalf("bounced %v, want the warm hibernator", bounced) |
| + } |
| +} |
| + |
| +func TestBounceExecRoutesWithoutAnActivator(t *testing.T) { |
| + routes := []registry.Route{execRoute("exec.example.com", "bab", "eleven")} |
| + if n := bounceExecRoutes(routes, [][2]string{{"bab", "eleven"}}, nil); n != 0 { |
| + t.Fatalf("bounced %d with no activator armed, want 0 and no panic", n) |
| + } |
| +} |
| + |
| +// ---- the agent's leg, and what makes it run ---------------------------------- |
| + |
| +// TestRestartRequestLegBouncesAndRelays: one serial move's worth of work — the |
| +// agent's own children cycled in-process, and one marker touched for the units |
| +// only root can reach. |
| +func TestRestartRequestLegBouncesAndRelays(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + marker := filepath.Join(dd, restartRequestMarker) |
| + routes := []registry.Route{ |
| + execRoute("exec.example.com", "bab", "eleven"), |
| + unitRoute("unit.example.com", "bab", "eleven"), |
| + } |
| + var bounced []string |
| + bounce := func(h string) bool { bounced = append(bounced, h); return true } |
| + cache := map[[2]string]string{} |
| + |
| + // Baseline (what restartSeed does at arming): silent, no marker. |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked { |
| + t.Fatal("the baselining leg asked for a root pass — first sight must be silent") |
| + } |
| + if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("marker written on a first sight (err %v)", err) |
| + } |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); !asked { |
| + t.Fatal("a moved restart generation must ask for a root pass") |
| + } |
| + if !slices.Equal(bounced, []string{"exec.example.com"}) { |
| + t.Fatalf("bounced %v, want the exec child only (the unit route is root's)", bounced) |
| + } |
| + if _, err := os.Stat(marker); err != nil { |
| + t.Fatalf("no marker after a request: %v", err) |
| + } |
| + |
| + // And a leg with nothing moved neither bounces nor re-asks. |
| + bounced = nil |
| + if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked { |
| + t.Fatal("an unmoved generation asked for a root pass") |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced %v with nothing moved", bounced) |
| + } |
| +} |
| + |
| +// TestFastTickRunsTheRestartLegOnASerialMove pins the wiring the redesign rests |
| +// on: the restart check rides the SERIAL, which is what keeps an idle box at |
| +// one GET per tick. A boot seed must not run it (that would cycle processes for |
| +// bumps that happened while the box was down), an unchanged serial must not run |
| +// it, and a real move must — once, after the pass set. |
| +func TestFastTickRunsTheRestartLegOnASerialMove(t *testing.T) { |
| + env := newFastFixture(t) |
| + var passes, legs int |
| + d := env.deps(func() { passes++ }) |
| + d.restartLeg = func(context.Context) { |
| + legs++ |
| + if legs > passes { |
| + t.Errorf("the restart leg ran before/without the pass set (legs=%d passes=%d)", legs, passes) |
| + } |
| + } |
| + now := time.Unix(1754640000, 0) |
| + ctx := context.Background() |
| + |
| + if err := release.BumpSerial(ctx, env.store, now); err != nil { |
| + t.Fatalf("BumpSerial: %v", err) |
| + } |
| + fastTick(ctx, d, now) // first sight of a present serial: seed only |
| + if legs != 0 { |
| + t.Fatalf("boot seed ran the restart leg %d times, want 0", legs) |
| + } |
| + fastTick(ctx, d, now.Add(10*time.Second)) // unchanged |
| + if legs != 0 { |
| + t.Fatalf("unchanged serial ran the restart leg %d times, want 0", legs) |
| + } |
| + if err := release.BumpSerial(ctx, env.store, now); err != nil { |
| + t.Fatalf("BumpSerial: %v", err) |
| + } |
| + fastTick(ctx, d, now.Add(20*time.Second)) |
| + if legs != 1 { |
| + t.Fatalf("a serial move ran the restart leg %d times, want 1", legs) |
| + } |
| +} |
| + |
| +// ---- the root pass ----------------------------------------------------------- |
| + |
| +func marksOf(t *testing.T, path string) *restartMarks { |
| + t.Helper() |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read marks: %v", err) |
| + } |
| + m := &restartMarks{} |
| + if err := json.Unmarshal(raw, m); err != nil { |
| + t.Fatalf("decode marks %s: %v", raw, err) |
| + } |
| + return m |
| +} |
| + |
| +// TestRestartPassSeedsWithoutReplaying is the expensive lesson this repo has |
| +// already paid for once: a box that has never recorded a baseline records one |
| +// and restarts NOTHING. Every app on a freshly upgraded box has a restart |
| +// generation the moment anyone has ever used the verb, and cycling them all on |
| +// the first pass would take the box out. |
| +func TestRestartPassSeedsWithoutReplaying(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + routes := []registry.Route{ |
| + unitRoute("a.example.com", "bab", "eleven"), |
| + unitRoute("b.example.com", "bab", "titogo"), |
| + } |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("the seeding pass cycled %v, want nothing", rec.hosts) |
| + } |
| + m := marksOf(t, path) |
| + if len(m.Apps) != 2 { |
| + t.Fatalf("marks = %v, want both apps baselined", m.Apps) |
| + } |
| + // And the very next pass is a quiet one, not a replay. |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("the pass after seeding cycled %v, want nothing", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassCyclesOnMovement is the feature working: baseline, request, |
| +// dispatch — for every non-hibernating route of THAT app and no other. |
| +func TestRestartPassCyclesOnMovement(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + sleeping := unitRoute("sleep.example.com", "bab", "eleven") |
| + sleeping.Hibernate = true |
| + routes := []registry.Route{ |
| + unitRoute("a.example.com", "bab", "eleven"), |
| + namedUnitRoute("console.example.com", "bab", "eleven", "console.service"), |
| + sleeping, |
| + unitRoute("z.example.com", "bab", "titogo"), // a bystander app |
| + } |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, path, rec.fn) // baseline |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + |
| + slices.Sort(rec.hosts) |
| + want := []string{"a.example.com", "console.example.com"} |
| + if !slices.Equal(rec.hosts, want) { |
| + t.Fatalf("cycled %v, want %v — the hibernating route must be left asleep and another app's route untouched", |
| + rec.hosts, want) |
| + } |
| + if got, want := marksOf(t, path).Apps[restartMarkKey("bab", "eleven")], restartEtag(t, ctx, s, "bab", "eleven"); got != want { |
| + t.Fatalf("mark = %q, want the etag just acted on %q", got, want) |
| + } |
| + // Idempotent: the same request is not served twice. |
| + rec.hosts = nil |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("a second pass on the same generation cycled %v, want nothing", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassActsOnAnAppsFirstEverRequest: an app with no object yet is |
| +// recorded as ABSENT, and the object's later appearance is movement away from |
| +// that recorded value. Both halves matter — without the recorded absence, the |
| +// first `carlos restart` for every app is either dropped (if "no mark" seeds) |
| +// or joined by a spurious cycle of every other app in the same state (if "no |
| +// mark" dispatches). |
| +func TestRestartPassActsOnAnAppsFirstEverRequest(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + |
| + restartPass(ctx, routes, s, path, rec.fn) // baseline: the app has no object at all |
| + if got := marksOf(t, path).Apps[restartMarkKey("bab", "eleven")]; got != restartAbsent { |
| + t.Fatalf("mark = %q, want the recorded absent sentinel", got) |
| + } |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"a.example.com"}) { |
| + t.Fatalf("cycled %v, want the app's route on its first ever request", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassSeedsAnAppItHasNeverSeen is the storm fix (review, Critical). |
| +// A route added to this box for an app that was restarted somewhere else weeks |
| +// ago must be RECORDED, not cycled: this box owes nothing to a request it never |
| +// saw, and reading "no mark" as movement is what turned a registry that landed |
| +// after the marks file into a box that try-restarted its entire route table in |
| +// one pass. |
| +func TestRestartPassSeedsAnAppItHasNeverSeen(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + askRestart(t, ctx, s, "bab", "titogo") // both restarted long ago, elsewhere |
| + first := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + restartPass(ctx, first, s, path, rec.fn) // this box knows about eleven only |
| + |
| + // titogo's route arrives now — a fresh instance, mid-provision until this |
| + // moment. Its app's object has existed for weeks. |
| + both := append(first, unitRoute("b.example.com", "bab", "titogo")) |
| + restartPass(ctx, both, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("a newly-seen app cycled %v, want nothing — this box never saw that request", rec.hosts) |
| + } |
| + if got, want := marksOf(t, path).Apps[restartMarkKey("bab", "titogo")], restartEtag(t, ctx, s, "bab", "titogo"); got != want { |
| + t.Fatalf("mark = %q, want the seeded etag %q", got, want) |
| + } |
| + // And it is live from here: the NEXT request cycles it. |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + restartPass(ctx, both, s, path, rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"b.example.com"}) { |
| + t.Fatalf("cycled %v, want the newly-seeded app's route on the next real request", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassWithNoRoutesWritesNothing: a box whose registry has not landed |
| +// yet (fresh machine, /data restoring from litestream) must not be able to |
| +// record "I know about no apps" — that file is what makes every route it later |
| +// gains look like movement. |
| +func TestRestartPassWithNoRoutesWritesNothing(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + |
| + restartPass(ctx, nil, s, path, rec.fn) |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a pass with zero adoptable routes wrote %s (err %v) — it knows nothing and may claim nothing", path, err) |
| + } |
| + // The registry lands; the app is seeded, not cycled. |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("cycled %v after the registry landed, want a silent baseline", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassDefersABaselineWithReadErrors: a baseline that omits the apps |
| +// a transient 503 hid is a baseline that turns those apps into movement on the |
| +// next pass. Nothing is written until a pass can read every app. |
| +func TestRestartPassDefersABaselineWithReadErrors(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + askRestart(t, ctx, s, "bab", "titogo") |
| + routes := []registry.Route{ |
| + unitRoute("a.example.com", "bab", "eleven"), |
| + unitRoute("b.example.com", "bab", "titogo"), |
| + } |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{} |
| + |
| + blip := &failingKeyStore{Store: s, failKey: release.RestartGenKey("bab", "eleven"), err: errors.New("503 slow down")} |
| + restartPass(ctx, routes, blip, path, rec.fn) |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a baselining pass with a read error wrote %s (err %v)", path, err) |
| + } |
| + |
| + // A clean pass baselines both, and cycles neither. |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("the clean baselining pass cycled %v, want nothing", rec.hosts) |
| + } |
| + if m := marksOf(t, path); len(m.Apps) != 2 { |
| + t.Fatalf("marks = %v, want both apps", m.Apps) |
| + } |
| +} |
| + |
| +// TestRestartPassRefusesWithoutALock: no lock means either a concurrent root |
| +// pass or a /data this pass cannot write. Dispatching under the first |
| +// double-restarts one request; under the second it restarts hourly forever, |
| +// since the mark can never advance. Neither is allowed to happen. |
| +func TestRestartPassRefusesWithoutALock(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "restart-marks.json") |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, path, rec.fn) // baseline, with a working lock |
| + |
| + // A directory where the lock file needs to be: opening it fails (EISDIR), |
| + // exactly as opening it would fail on a read-only or full /data. |
| + if err := os.Remove(path + ".lock"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(path+".lock", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("cycled %v with no lock — an unrecordable restart repeats forever", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassSerializesConcurrentPasses: three root units can run this at |
| +// once (carlos-restart, carlos-stage, carlos-adopt). Under the lock, one |
| +// request produces ONE dispatch however many passes race for it. |
| +func TestRestartPassSerializesConcurrentPasses(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + var mu sync.Mutex |
| + var hosts []string |
| + dispatch := func(h string) error { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + hosts = append(hosts, h) |
| + return nil |
| + } |
| + restartPass(ctx, routes, s, path, dispatch) // baseline |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + var wg sync.WaitGroup |
| + for range 3 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + restartPass(ctx, routes, s, path, dispatch) |
| + }() |
| + } |
| + wg.Wait() |
| + if len(hosts) != 1 { |
| + t.Fatalf("three concurrent passes dispatched %v, want exactly one — the flock is what makes a request singular", hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassAdvancesTheMarkOnAFailedDispatch: a restart request is a |
| +// point-in-time event, not a desired state. A named unit that will not come |
| +// back must be reported, not retried on every pass until the end of time. |
| +func TestRestartPassAdvancesTheMarkOnAFailedDispatch(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{namedUnitRoute("console.example.com", "bab", "eleven", "wrong.service")} |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + rec := &recordingRestart{fail: map[string]error{"console.example.com": errors.New("Unit wrong.service not loaded")}} |
| + restartPass(ctx, routes, s, path, rec.fn) // baseline |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 1 { |
| + t.Fatalf("dispatched %v, want one attempt", rec.hosts) |
| + } |
| + rec.hosts = nil |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("a failed dispatch was retried (%v) — the request must not become a standing order", rec.hosts) |
| + } |
| +} |
| + |
| +// TestRestartPassUnreadableMarksReBaseline: a wiped or corrupt marks file |
| +// fails toward NOT restarting. The cost is one dropped request, logged and |
| +// re-runnable; the alternative is a box that cycles everything it serves the |
| +// moment a state file goes missing. |
| +func TestRestartPassUnreadableMarksReBaseline(t *testing.T) { |
| + ctx, s := genKit(t) |
| + askRestart(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := &recordingRestart{} |
| + |
| + restartPass(ctx, routes, s, path, rec.fn) |
| + if len(rec.hosts) != 0 { |
| + t.Fatalf("a corrupt marks file cycled %v, want nothing", rec.hosts) |
| + } |
| + if got, want := marksOf(t, path).Apps[restartMarkKey("bab", "eleven")], restartEtag(t, ctx, s, "bab", "eleven"); got != want { |
| + t.Fatalf("re-baselined mark = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// ---- the handover between the two halves ------------------------------------- |
| + |
| +// TestSeedRestartMarksIsCreateOnly: the agent writes the baseline as it arms, |
| +// so a request made before the root pass has ever run is not silently dropped |
| +// — but it must never overwrite a mark the root pass has already advanced, or |
| +// a served request would be served again. Absent sightings ARE recorded: that |
| +// sentinel is what makes an app's first ever request movement. |
| +func TestSeedRestartMarksIsCreateOnly(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + cache := map[[2]string]string{ |
| + {"bab", "eleven"}: "etag-1", |
| + {"bab", "titogo"}: genAbsent, // seen, and it has no object |
| + } |
| + if err := seedRestartMarks(path, cache); err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + m := marksOf(t, path) |
| + if m.Apps[restartMarkKey("bab", "eleven")] != "etag-1" || |
| + m.Apps[restartMarkKey("bab", "titogo")] != restartAbsent { |
| + t.Fatalf("seeded marks = %v, want the etag and the recorded absence", m.Apps) |
| + } |
| + |
| + cache[[2]string{"bab", "eleven"}] = "etag-2" |
| + if err := seedRestartMarks(path, cache); err != nil { |
| + t.Fatalf("second seed: %v", err) |
| + } |
| + if got := marksOf(t, path).Apps[restartMarkKey("bab", "eleven")]; got != "etag-1" { |
| + t.Fatalf("mark = %q after a second seed, want the original %q — seeding must never overwrite", got, "etag-1") |
| + } |
| +} |
| + |
| +// TestSeedRestartMarksRefusesAnEmptyBaseline: a sweep that saw nothing is most |
| +// often a box whose registry has not landed yet. A file claiming "this box |
| +// knows about no apps" is exactly what makes the root pass treat every route |
| +// the box later gains as movement. |
| +func TestSeedRestartMarksRefusesAnEmptyBaseline(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "restart-marks.json") |
| + if err := seedRestartMarks(path, map[[2]string]string{}); err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an empty sweep wrote %s (err %v), want no file at all", path, err) |
| + } |
| +} |
| + |
| +// TestSeededBaselineIsWhatTheRootPassTrusts closes the handover: the agent |
| +// seeds, a member then asks, and the root pass — running for the FIRST time, |
| +// with no memory of its own — still cycles. Without the seed that pass would |
| +// baseline instead and the request would vanish having been answered |
| +// "requested". |
| +func TestSeededBaselineIsWhatTheRootPassTrusts(t *testing.T) { |
| + ctx, s := genKit(t) |
| + dd := t.TempDir() |
| + routes := []registry.Route{unitRoute("a.example.com", "bab", "eleven")} |
| + askRestart(t, ctx, s, "bab", "eleven") // an older request, already served |
| + |
| + // The agent's first tick: baseline in memory, baseline on disk. |
| + cache := map[[2]string]string{} |
| + restartGenPass(ctx, routes, s, cache) |
| + if err := seedRestartMarks(restartMarksPath(dd), cache); err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + |
| + askRestart(t, ctx, s, "bab", "eleven") // the member types `carlos restart` |
| + moved, _ := restartGenPass(ctx, routes, s, cache) |
| + if len(moved) != 1 { |
| + t.Fatalf("the agent missed the request: moved = %v", moved) |
| + } |
| + rec := &recordingRestart{} |
| + restartPass(ctx, routes, s, restartMarksPath(dd), rec.fn) |
| + if !slices.Equal(rec.hosts, []string{"a.example.com"}) { |
| + t.Fatalf("the first root pass cycled %v, want the requested route", rec.hosts) |
| + } |
| +} |
diff --git a/internal/host/units/carlos-restart.path b/internal/host/units/carlos-restart.path| new file mode 100644 |
| index 000000000..f5e20875d |
| --- /dev/null |
| +++ b/internal/host/units/carlos-restart.path |
| @@ -0,0 +1,27 @@ |
| +# Member-requested process restarts (issue #113) — carlos-adopt.path's twin |
| +# for the third half the sandboxed agent cannot do itself. The agent's |
| +# serial poll sees the console's bump within ~2s, compares the app's restart |
| +# generation, and bounces its OWN exec children immediately — but it cannot |
| +# restart a systemd unit: not a carlos-app@ tenant, and not the bespoke unit an |
| +# addr-backed route names (issue #110's consoles). Touching |
| +# /data/restart-requested relays that to a root pass. |
| +# |
| +# It starts carlos-restart.service — `carlos adopt -restart-only` — |
| +# deliberately NOT carlos-adopt.service or carlos-config-sync.service. A |
| +# restart request changes no version and writes no config, so a full pass would |
| +# stage releases and nag about canaries on a per-request trigger, and the |
| +# config pass would find no changed env file and cycle nothing at all. Same |
| +# split, and the same reason, as carlos-stage.path having its own service. |
| +# |
| +# /data is hardcoded like every path in these unit files — a box with a |
| +# nonstandard data dir simply never fast-triggers and keeps the hourly floor |
| +# (carlos-adopt.timer runs the same comparison). |
| +[Unit] |
| +Description=CARLOS instance restart on agent request (carlos restart) |
| + |
| +[Path] |
| +PathChanged=/data/restart-requested |
| +Unit=carlos-restart.service |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-restart.service b/internal/host/units/carlos-restart.service| new file mode 100644 |
| index 000000000..fe55e1440 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-restart.service |
| @@ -0,0 +1,53 @@ |
| +# One RESTART-REQUEST pass (`carlos restart`, issue #113): cycle the units of |
| +# every app whose restart generation has moved since this box last acted, and |
| +# nothing else. Started only by carlos-restart.path, which fires when the agent |
| +# touches /data/restart-requested; it has no [Install] section because nothing |
| +# else should ever want it. |
| +# |
| +# Deliberately NOT carlos-adopt.service: that unit also runs release.Pass, the |
| +# capability converge and the canary-TTL nag, so pointing a per-request trigger |
| +# at it would let one tenant's `carlos restart` cut over ANOTHER app's pending |
| +# release within seconds, and would nag on every request instead of hourly — |
| +# the same split carlos-config-sync.service and carlos-stage.service already |
| +# make. Same root + hardening shape as those two, which is what `systemctl |
| +# try-restart` requires. |
| +# |
| +# It needs neither CARLOS_RELEASE_PUBKEY nor CARLOS_SECRETS_KEY: a restart |
| +# verifies no signature and renders no config. The EnvironmentFile is still |
| +# read for CARLOS_DEPLOYMENT_BUCKET/_DIR, which is where the request itself is. |
| +[Unit] |
| +Description=carlos instance restart (on agent request) |
| +After=network-online.target |
| +Wants=network-online.target |
| +# A path unit re-triggers as often as members ask; the default start rate limit |
| +# would latch this unit into start-limit-hit after a burst and then ignore every |
| +# later trigger until an operator reset it. ([Unit], not [Service] — systemd |
| +# moved these two keys in v230.) |
| +StartLimitIntervalSec=0 |
| + |
| +[Service] |
| +Type=oneshot |
| +# CARLOS_DEPLOYMENT_BUCKET/_DIR lives here. |
| +EnvironmentFile=-/etc/carlos/host.env |
| +ExecStart=/opt/carlos/carlos adopt -restart-only |
| + |
| +# --- hardening --- |
| +# Runs as ROOT deliberately, exactly like carlos-adopt.service: this pass |
| +# restarts carlos-app@ tenants and operator-named units via systemctl. |
| +# NoNewPrivileges and the syscall filter are omitted for the systemctl child; |
| +# the rest applies. |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +TasksMax=64 |
diff --git a/internal/release/restartgen.go b/internal/release/restartgen.go| new file mode 100644 |
| index 000000000..6ca18524f |
| --- /dev/null |
| +++ b/internal/release/restartgen.go |
| @@ -0,0 +1,107 @@ |
| +// The restart signal: one tiny object per app that a restart REQUEST touches, |
| +// so a box can learn "cycle this app's processes" from a single cheap etag — |
| +// the pull-based answer to issue #113 (a member must be able to cycle a wedged |
| +// instance with no version and no config to push, and with no box access at |
| +// all). Deliberately the same shape as appconfig's config-gen object, because |
| +// it is the same mechanism one step sideways: a generation object nobody |
| +// signs, watched by etag, whose movement means "do the thing", never "trust |
| +// this content". |
| +// |
| +// Bucket layout addition: |
| +// |
| +// apps/<acct>/<app>/restart-gen.json CAS-guarded, schema-1 generation object |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "time" |
| +) |
| + |
| +// RestartGeneration is the object `carlos restart` touches — |
| +// apps/<acct>/<app>/restart-gen.json. Change detection is by ETAG, never by |
| +// content: the fields exist so a human debugging "why did this bounce?" can |
| +// see when the last request went out, and so each touch's bytes are distinct |
| +// (DirStore's etag is content-derived, so a byte-identical rewrite would not |
| +// move it, and the movement IS the signal). |
| +// |
| +// It carries no authority whatsoever, which is what makes an unsigned object |
| +// safe here: the worst a forged touch can do is restart processes that were |
| +// already running the version their own signed pointer names. Nothing about |
| +// what runs is decided by this file. |
| +// |
| +// ABSENT IS LEGAL FOREVER. An app that has never been restarted this way has |
| +// no object and never will until someone asks; boxes read the absence, record |
| +// it, and go on. Old boxes that predate this feature ignore the key entirely |
| +// — the wire shape is purely additive, and nothing that parses today's app |
| +// prefix parses this. |
| +// |
| +// It is a SIBLING object rather than a field on anything existing for |
| +// appconfig.Generation's reason: a field would make every watcher re-read and |
| +// re-parse a whole manifest to learn one bit, and would race the very write |
| +// it signals. |
| +type RestartGeneration struct { |
| + Schema int `json:"schema"` // 1 |
| + TouchedAt string `json:"touched_at"` |
| + Nonce string `json:"nonce"` |
| +} |
| + |
| +// RestartGenKey is the bucket key of app's restart-generation object, derived |
| +// from AppPrefix like every other per-app key: everything an app owns sits |
| +// under one account-scoped prefix, permanently. |
| +func RestartGenKey(acct, app string) string { return AppPrefix(acct, app) + "restart-gen.json" } |
| + |
| +// TouchRestartGen bumps app's restart generation — the request every box |
| +// serving that app converges on within seconds. |
| +// |
| +// A CAS conflict is SUCCESS, not an error: it means another writer touched the |
| +// object between our read and our write, so the signal this function exists to |
| +// fire has already fired. Retrying would only produce a second, redundant |
| +// request — and a second restart of processes that are cycling right now is |
| +// the one outcome a restart verb must not manufacture out of a race. |
| +// |
| +// os.ErrNotExist on the read means first touch, so the write is create-only |
| +// (etag ""), which is what makes the conflict carve-out safe: two first |
| +// touches race, one wins, both callers are correct — both asked for a restart, |
| +// and exactly one restart is what the box will do. |
| +func TouchRestartGen(ctx context.Context, s Store, acct, app string) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("restart: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("restart: invalid app %q", app) |
| + } |
| + key := RestartGenKey(acct, app) |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" |
| + } |
| + nonce := make([]byte, 16) |
| + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { |
| + return err |
| + } |
| + raw, err := json.Marshal(RestartGeneration{ |
| + Schema: 1, |
| + TouchedAt: time.Now().UTC().Format(time.RFC3339), |
| + Nonce: hex.EncodeToString(nonce), |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| + if errors.Is(err, ErrCASConflict) { |
| + return nil |
| + } |
| + return err |
| + } |
| + return nil |
| +} |
diff --git a/internal/release/restartgen_test.go b/internal/release/restartgen_test.go| new file mode 100644 |
| index 000000000..afdf59581 |
| --- /dev/null |
| +++ b/internal/release/restartgen_test.go |
| @@ -0,0 +1,135 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// restartEtag reads the app's restart-generation etag, "" for absent — the |
| +// exact observation a box's sweep makes, so a test asserting on it asserts on |
| +// the signal itself rather than on this file's internals. |
| +func restartEtag(t *testing.T, s Store, acct, app string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), RestartGenKey(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + t.Fatalf("read restart generation: %v", err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestTouchRestartGenCreatesAndMoves(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { |
| + t.Fatalf("first TouchRestartGen: %v", err) |
| + } |
| + raw, first, err := s.Get(ctx, RestartGenKey("bab", "keymail")) |
| + if err != nil { |
| + t.Fatalf("Get after first touch: %v", err) |
| + } |
| + var g RestartGeneration |
| + if err := json.Unmarshal(raw, &g); err != nil { |
| + t.Fatalf("decode restart generation: %v", err) |
| + } |
| + if g.Schema != 1 { |
| + t.Fatalf("Schema = %d, want 1", g.Schema) |
| + } |
| + if _, err := time.Parse(time.RFC3339, g.TouchedAt); err != nil { |
| + t.Fatalf("TouchedAt %q is not RFC3339: %v", g.TouchedAt, err) |
| + } |
| + if nonce, err := hex.DecodeString(g.Nonce); err != nil || len(nonce) == 0 { |
| + t.Fatalf("Nonce = %q, want nonempty hex (decode err %v)", g.Nonce, err) |
| + } |
| + // The etag is the whole point: a second request must be a DIFFERENT |
| + // object, or the sweep watching it sees nothing and nothing restarts. |
| + if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { |
| + t.Fatalf("second TouchRestartGen: %v", err) |
| + } |
| + if second := restartEtag(t, s, "bab", "keymail"); second == first { |
| + t.Fatalf("etag %q unchanged after a second request — the restart signal never fires", second) |
| + } |
| +} |
| + |
| +// racingRestartPutStore lands a different writer's value at key just before |
| +// forwarding the caller's Put, which then fails CAS against the etag the |
| +// caller read — two people (or a person and a retrying CI job) asking for a |
| +// restart at the same moment. |
| +type racingRestartPutStore struct { |
| + Store |
| + key string |
| + done bool |
| +} |
| + |
| +func (r *racingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == r.key && !r.done { |
| + r.done = true |
| + other := []byte(`{"schema":1,"touched_at":"2026-01-01T00:00:00Z","nonce":"00112233445566778899aabbccddeeff"}`) |
| + if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil { |
| + return "", err |
| + } |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestTouchRestartGenCASConflictIsSuccess(t *testing.T) { |
| + inner := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, inner, "bab", "keymail"); err != nil { |
| + t.Fatalf("seed TouchRestartGen: %v", err) |
| + } |
| + racing := &racingRestartPutStore{Store: inner, key: RestartGenKey("bab", "keymail")} |
| + if err := TouchRestartGen(ctx, racing, "bab", "keymail"); err != nil { |
| + t.Fatalf("TouchRestartGen on a lost CAS race: %v, want nil (the request already fired)", err) |
| + } |
| + if !racing.done { |
| + t.Fatal("the racing writer never ran — this test proved nothing") |
| + } |
| +} |
| + |
| +// failingRestartPutStore fails every Put with a fixed non-CAS error. |
| +type failingRestartPutStore struct { |
| + Store |
| + err error |
| +} |
| + |
| +func (f *failingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + return "", f.err |
| +} |
| + |
| +// TestTouchRestartGenSurfacesRealPutErrors is the other half of the CAS |
| +// carve-out: ONLY ErrCASConflict is success. Any other store failure means |
| +// nobody asked for anything, and a caller told "requested" would stand there |
| +// waiting for a restart that is never coming. |
| +func TestTouchRestartGenSurfacesRealPutErrors(t *testing.T) { |
| + s := &failingRestartPutStore{Store: NewDirStore(t.TempDir()), err: errors.New("bucket is on fire")} |
| + if err := TouchRestartGen(context.Background(), s, "bab", "keymail"); err == nil { |
| + t.Fatal("TouchRestartGen swallowed a non-CAS Put error, want it surfaced") |
| + } |
| +} |
| + |
| +func TestTouchRestartGenRefusesInvalidNames(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, s, "../etc", "keymail"); err == nil { |
| + t.Fatal("TouchRestartGen accepted an invalid account") |
| + } |
| + if err := TouchRestartGen(ctx, s, "bab", "../etc"); err == nil { |
| + t.Fatal("TouchRestartGen accepted an invalid app") |
| + } |
| +} |
| + |
| +// TestRestartGenKeyIsAnAppSibling pins the key's shape: under the app's own |
| +// account-scoped prefix, beside config-gen.json rather than inside anything. |
| +func TestRestartGenKeyIsAnAppSibling(t *testing.T) { |
| + if got, want := RestartGenKey("bab", "keymail"), "apps/bab/keymail/restart-gen.json"; got != want { |
| + t.Fatalf("RestartGenKey = %q, want %q", got, want) |
| + } |
| +} |
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index 3c485445b..075569d76 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -39,6 +39,13 @@ var allowedBoxKeys = map[string][]string{ |
| // on the hourly floor. Same publication argument — a platform-own unit's |
| // liveness on an opaquely-labelled box, no tenant in it anywhere. |
| // |
| + // "restart_path_running" is the issue #113 sibling of both: whether this |
| + // box's restart trigger unit (carlos-restart.path) is armed, i.e. whether a |
| + // member's `carlos restart` reaches its units in seconds or waits out the |
| + // hourly floor. Identical publication argument — one platform-own unit's |
| + // liveness on an opaquely-labelled box, and no tenant anywhere in it (it |
| + // says nothing about WHICH app was restarted, or that any was). |
| + // |
| // "metrics" is the one key in this allowlist that is EXEMPT from the |
| // band-word-not-a-number rule every other Box field follows (Task 5, |
| // internal/status/snapshot.go's package doc): it carries raw byte |
| @@ -56,7 +63,7 @@ var allowedBoxKeys = map[string][]string{ |
| // safe for exactly the same bucket-private reason: BuildFleet's |
| // field-by-field copy never names "metrics" at all, so no hostname here |
| // can reach FleetBox or fleet.json regardless of what this map holds. |
| - "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "expired_canaries", "metrics"}, |
| + "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries", "metrics"}, |
| "routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| "replication": {"healthy", "lag_seconds"}, |
| "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions"}, |
| @@ -67,7 +74,7 @@ var allowedBoxKeys = map[string][]string{ |
| // allowedBoxKeys: every new field is a deliberate publication decision. |
| var allowedFleetKeys = map[string][]string{ |
| "": {"generated_at", "boxes", "instances", "capacity", "apps", "apps_unlisted", "services", "replication", "adoption_running", "expired_canaries"}, |
| - "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running", "config_path_running", "expired_canaries"}, |
| + "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries"}, |
| "boxes[].routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| "instances": {"total", "static", "resident", "hibernating", "awake", "asleep"}, |
| "capacity": {"level"}, |
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go| index 4143f75a0..aec3da27b 100644 |
| --- a/internal/status/dryrun.go |
| +++ b/internal/status/dryrun.go |
| @@ -141,6 +141,7 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| lag := int64(120) |
| adopting := true // a known reading, so adoption_running publishes non-null and gets scanned |
| configPath := true // ditto for config_path_running (issue #44) |
| + restartPath := true // ditto for restart_path_running (issue #113) |
| expiredCanaries := 2 // ditto for expired_canaries — a known count, not the unexercised nil path |
| // Task 5's raw Metrics fixtures: distinctive values (see |
| @@ -151,14 +152,15 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| load1 := 42.42 |
| probes := Probes{ |
| - MachineID: "ip-10-0-0-7", |
| - Region: "us-east-1", |
| - MemFrac: &memFrac, |
| - DiskFrac: &diskFrac, |
| - LagSecs: &lag, |
| - AdoptRunning: &adopting, |
| - ConfigPathRunning: &configPath, |
| - ExpiredCanaries: &expiredCanaries, |
| + MachineID: "ip-10-0-0-7", |
| + Region: "us-east-1", |
| + MemFrac: &memFrac, |
| + DiskFrac: &diskFrac, |
| + LagSecs: &lag, |
| + AdoptRunning: &adopting, |
| + ConfigPathRunning: &configPath, |
| + RestartPathRunning: &restartPath, |
| + ExpiredCanaries: &expiredCanaries, |
| // Every hibernating route reports "listening" so Awake/Asleep publish |
| // non-null — an unexercised null field is a field DryRun can't scan. |
| Listening: func(string) *bool { v := true; return &v }, |
diff --git a/internal/status/fleet.go b/internal/status/fleet.go| index eaf12a48a..463f4b656 100644 |
| --- a/internal/status/fleet.go |
| +++ b/internal/status/fleet.go |
| @@ -50,6 +50,11 @@ type FleetBox struct { |
| // page shows a tile for them, and this one is a per-box operational |
| // fact, not a headline about the fleet. |
| ConfigPathRunning *bool `json:"config_path_running"` |
| + // RestartPathRunning appears per-box for exactly the reason |
| + // ConfigPathRunning does, and is not rolled up for the same reason: it is a |
| + // per-box operational fact ("this box's restart trigger is not watching"), |
| + // not a headline about the fleet. |
| + RestartPathRunning *bool `json:"restart_path_running"` |
| // ExpiredCanaries appears per-box for the same reason AdoptionRunning |
| // does: the fleet-wide sum says something is expired somewhere, this |
| // says on which box, without naming which route. Published in the FEED |
| @@ -165,14 +170,15 @@ func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, n |
| for i, b := range boxes { |
| stale[i] = BoxStale(b, now, period) |
| f.Boxes = append(f.Boxes, FleetBox{ |
| - Label: b.Label, |
| - Region: b.Region, |
| - Load: b.Load, |
| - Stale: stale[i], |
| - Routes: b.Routes, |
| - AdoptionRunning: b.AdoptionRunning, |
| - ConfigPathRunning: b.ConfigPathRunning, |
| - ExpiredCanaries: b.ExpiredCanaries, |
| + Label: b.Label, |
| + Region: b.Region, |
| + Load: b.Load, |
| + Stale: stale[i], |
| + Routes: b.Routes, |
| + AdoptionRunning: b.AdoptionRunning, |
| + ConfigPathRunning: b.ConfigPathRunning, |
| + RestartPathRunning: b.RestartPathRunning, |
| + ExpiredCanaries: b.ExpiredCanaries, |
| }) |
| } |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 5fb6f128e..39b18fc08 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -59,6 +59,13 @@ type Probes struct { |
| // half-working this field exists to end. nil is "never asked / couldn't |
| // tell" and publishes as null, same as every other probe here. |
| ConfigPathRunning *bool |
| + // RestartPathRunning is whether this box's restart trigger unit |
| + // (carlos-restart.path) is ACTIVE — ConfigPathRunning's issue #113 sibling, |
| + // same failure mode, sharper consequence: with it unarmed, a member who |
| + // runs `carlos restart` against a wedged instance is told "requested" and |
| + // waits out the hourly pass for a cycle they were led to expect in |
| + // seconds. nil is "never asked / couldn't tell" and publishes as null. |
| + RestartPathRunning *bool |
| // ExpiredCanaries is how many of this box's own canary/* routes have |
| // passed release.CanaryTTL (pipelines spec §5 "Canaries expire"), |
| // pre-computed by the caller against the release store — CollectBox |
| @@ -171,6 +178,14 @@ type Box struct { |
| // issue #44 carry no such key at all and decode to null, which is the |
| // honest reading of a box that never reported it. |
| ConfigPathRunning *bool `json:"config_path_running"` |
| + // RestartPathRunning is whether a member's `carlos restart` reaches this |
| + // box's units in seconds (carlos-restart.path armed) or only on the hourly |
| + // floor: true = armed, false = installed but not watching, null = unknown. |
| + // Same content and same privacy argument as ConfigPathRunning above — the |
| + // liveness of one of the platform's own units on an opaquely-labelled box. |
| + // Snapshots written before issue #113 carry no such key and decode to null, |
| + // which is the honest reading of a box that never reported it. |
| + RestartPathRunning *bool `json:"restart_path_running"` |
| // ExpiredCanaries publishes Probes.ExpiredCanaries straight through — a |
| // count, never hostnames (pipelines spec §5): which canary route expired |
| // is exactly the kind of identifying detail this package's whole design |
| @@ -353,16 +368,17 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| box := Box{ |
| - GeneratedAt: now, |
| - Label: Label(p.MachineID), |
| - Region: p.Region, |
| - Routes: counts, |
| - Load: bandWord(p.MemFrac, loadWords), |
| - Capacity: bandWord(worst, capacityWords), |
| - Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs}, |
| - AdoptionRunning: p.AdoptRunning, |
| - ConfigPathRunning: p.ConfigPathRunning, |
| - ExpiredCanaries: p.ExpiredCanaries, |
| + GeneratedAt: now, |
| + Label: Label(p.MachineID), |
| + Region: p.Region, |
| + Routes: counts, |
| + Load: bandWord(p.MemFrac, loadWords), |
| + Capacity: bandWord(worst, capacityWords), |
| + Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs}, |
| + AdoptionRunning: p.AdoptRunning, |
| + ConfigPathRunning: p.ConfigPathRunning, |
| + RestartPathRunning: p.RestartPathRunning, |
| + ExpiredCanaries: p.ExpiredCanaries, |
| } |
| // Metrics attaches only when something is known — an all-unknown Probes |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 928b9d609..4e83e905b 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -758,6 +758,53 @@ func TestConfigPathRunningIsWireAdditive(t *testing.T) { |
| } |
| } |
| +// TestRestartPathRunningTravelsPerBox is TestConfigPathRunningTravelsPerBox's |
| +// issue #113 twin, and it exists for a sharper reason than symmetry: with |
| +// carlos-restart.path unarmed, a member runs `carlos restart` at a wedged |
| +// instance, is told "requested", and waits for a cycle that will not come until |
| +// the hourly pass. Nothing inside the agent can see that — it writes its marker |
| +// either way — so this reading is the only thing that can. Unknown stays null. |
| +func TestRestartPathRunningTravelsPerBox(t *testing.T) { |
| + if b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()); b.RestartPathRunning != nil { |
| + t.Errorf("restart_path_running = %v, want null when the probe never answered", *b.RestartPathRunning) |
| + } |
| + for _, want := range []bool{true, false} { |
| + got := status.CollectBox(nil, status.Probes{MachineID: "m", RestartPathRunning: &want}, time.Now()).RestartPathRunning |
| + if got == nil || *got != want { |
| + t.Fatalf("RestartPathRunning=%v: box field = %v, want %v", want, got, want) |
| + } |
| + } |
| + |
| + s := teststore(t) |
| + now := time.Now() |
| + no := false |
| + b := status.CollectBox(nil, status.Probes{MachineID: "m", RestartPathRunning: &no}, now) |
| + if err := status.WriteBox(context.Background(), s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(f.Boxes) != 1 { |
| + t.Fatalf("fleet boxes = %d, want 1", len(f.Boxes)) |
| + } |
| + if got := f.Boxes[0].RestartPathRunning; got == nil || *got { |
| + t.Fatalf("fleet box restart_path_running = %v, want false — knowing WHICH box cannot cycle a wedged instance is the whole value", got) |
| + } |
| + |
| + // Wire-additive, like every field before it: a snapshot written before |
| + // issue #113 has no such key and must read as unknown, not a healthy false. |
| + var old status.Box |
| + raw := `{"generated_at":"2026-08-04T00:00:00Z","label":"box-old","routes":{"static":0,"resident":0,"hibernating":0,"awake":null,"asleep":null},"load":null,"capacity":null,"replication":{"healthy":null,"lag_seconds":null},"adoption_running":true,"config_path_running":true,"expired_canaries":null}` |
| + if err := json.Unmarshal([]byte(raw), &old); err != nil { |
| + t.Fatalf("a pre-#113 snapshot must still parse: %v", err) |
| + } |
| + if old.RestartPathRunning != nil { |
| + t.Fatalf("restart_path_running = %v, want null for a snapshot written before the field existed", *old.RestartPathRunning) |
| + } |
| +} |
| + |
| // TestFleetAdoptionWorstOf covers the aggregate: a box that is KNOWN not to be |
| // adopting is a defect the fleet number must never hide, so one false outranks |
| // any number of trues; an unknown box can't be summed into a confident true, |
diff --git a/main.go b/main.go| index 18735f150..58b38f113 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -59,6 +59,8 @@ func main() { |
| runPromote(args) |
| case "deploy": |
| runDeploy(args) |
| + case "restart": |
| + runRestart(args) |
| case "channels": |
| runChannels(args) |
| case "releases": |
| @@ -119,6 +121,7 @@ Your apps — ship and run software on a CARLOS deployment |
| promote move a version up the ladder: canary → edge → beta → stable |
| (reaching stable cuts the release's semver tag) |
| deploy ship + promote + wait until the URL serves the new version |
| + restart cycle an app's instances — no new version, no config change |
| rollback point a channel back at an earlier version |
| channels show what each of an app's channels is serving |
| releases every version shipped, newest first, with labels and tags |
diff --git a/restart.go b/restart.go| new file mode 100644 |
| index 000000000..25c9e5a31 |
| --- /dev/null |
| +++ b/restart.go |
| @@ -0,0 +1,86 @@ |
| +// restart.go is `carlos restart`: cycle an app's processes with no version |
| +// and no config change (issue #113). It is the member-facing answer to a |
| +// wedged instance, which until now meant `systemctl` on a box — and every |
| +// carlos command is designed for someone with no box access at all. |
| +// |
| +// Pull-based, like everything else here: the console touches one small object, |
| +// every box serving the app notices within seconds and cycles its own routes. |
| +// Nothing is pushed at a box, nothing wakes a sleeping instance, and no |
| +// release ladder or bake window is involved — there is no new version for one |
| +// to gate. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "strings" |
| +) |
| + |
| +// runRestart is the verb. API mode ONLY, like deploy: the request is a console |
| +// write, and the object it touches means nothing without a fleet reading it. |
| +func runRestart(args []string) { |
| + fs := flag.NewFlagSet("restart", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose instances to cycle") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + if name == "" { |
| + log.Fatal("usage: carlos restart -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + // Client-side gate before the server's, the same one `carlos apps create` |
| + // applies: a name the console is certain to refuse should cost a typo |
| + // nothing, and the shared write budget is spent per attempt. |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("restart: %v", err) |
| + } |
| + |
| + sess := requireRestartAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + res, err := sess.restart(context.Background(), acct, name) |
| + if err != nil { |
| + log.Fatalf("restart: %v", err) |
| + } |
| + // One line, and it says REQUESTED rather than restarted, because that is |
| + // what actually happened: the boxes converge on their own within seconds, |
| + // and this terminal is not watching them do it. Claiming a restart here |
| + // would be the same lie issue #110 was made of, told by the CLI instead of |
| + // a header. |
| + // |
| + // It also does not promise that everything is back up in seconds, because |
| + // that is not how the box behaves: long-running tenants are restarted |
| + // within seconds, while exec-backed instances are STOPPED within seconds |
| + // and respawn on their next request or scheduled run — which for an idle |
| + // app may be a while, and is the correct behaviour, not a delay to |
| + // apologise for. |
| + fmt.Fprintf(stdout, "%s for %s/%s — running instances stop within seconds; "+ |
| + "long-running ones restart, on-demand ones respawn on their next request "+ |
| + "(sleeping instances stay asleep and start fresh when next woken)\n", |
| + colorFor(stdout).ok("restart requested"), res.Account, res.App) |
| +} |
| + |
| +// requireRestartAPISession resolves signed-in mode for restart, refusing |
| +// direct-bucket mode with restart's OWN message — the pattern |
| +// requireDeployAPISession sets, and for its reason: a refusal that names |
| +// another verb's advice sends people somewhere that cannot help them. |
| +// |
| +// There is deliberately no headless equivalent to fall back to. A bucket-mode |
| +// operator asking for a restart already has the box (that is what bucket mode |
| +// means), so the honest answer is the box's own systemctl — not a bucket write |
| +// this CLI could make and no console would announce. |
| +func requireRestartAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos restart works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). restart is a member verb with no\n" + |
| + "direct-bucket equivalent — the request is a console write the fleet listens for. Unset the\n" + |
| + "store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
diff --git a/restart_test.go b/restart_test.go| new file mode 100644 |
| index 000000000..8232da794 |
| --- /dev/null |
| +++ b/restart_test.go |
| @@ -0,0 +1,153 @@ |
| +package main |
| + |
| +// The CLI half of `carlos restart` (issue #113): the wire shape it sends, the |
| +// answers it can get, and the one refusal it has to explain rather than |
| +// repeat. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestAPISessionRestartWireShape pins what handleAPIRestart requires: a bearer |
| +// POST to the app's /restart path, with the account segment carried verbatim |
| +// (the console resolves names against the caller's own memberships — this |
| +// client must never resolve one locally). |
| +func TestAPISessionRestartWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail") |
| + if err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/keymail-team/keymail/restart" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| + // The CANONICAL names come back from the console, not from what was typed. |
| + if res.Account != "bab" || res.App != "keymail" { |
| + t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) |
| + } |
| +} |
| + |
| +// TestAPISessionRestartAccountIDIsExact: -account-id travels as ?exact=sqid, |
| +// the console-side switch that matches the segment as a sqid ONLY — the |
| +// reference an ambiguity error tells you to reach for must not itself be |
| +// ambiguous. |
| +func TestAPISessionRestartAccountIDIsExact(t *testing.T) { |
| + var gotPath string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.RequestURI() |
| + json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) |
| + })) |
| + defer ts.Close() |
| + |
| + if _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab", Exact: true}, "keymail"); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + if !strings.HasSuffix(gotPath, "?exact=sqid") { |
| + t.Fatalf("path = %s, want the exact=sqid switch", gotPath) |
| + } |
| +} |
| + |
| +// TestAPISessionRestart404NamesBothCauses is the old-console case. net/http's |
| +// mux answers an unregistered route with the SAME byte-identical "404 page not |
| +// found" apiAccountApp gives a non-member, so there is nothing in the response |
| +// to tell them apart — and a message that claimed either one alone would send |
| +// somebody to fix the wrong thing. |
| +func TestAPISessionRestart404NamesBothCauses(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) // a console that predates this door, or a stranger's probe |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") |
| + if err == nil { |
| + t.Fatal("a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("error %q drops the membership explanation", err) |
| + } |
| + if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "redeploy the console") { |
| + t.Fatalf("error %q does not name the old-console cause and its fix", err) |
| + } |
| +} |
| + |
| +// TestAPISessionRestartServerErrorSurfacesVerbatim: a door that answered with |
| +// a real diagnosis (a 429 budget, a 503 with no bucket, a 502 that wrote |
| +// nothing) must reach the terminal in the console's own words — that text |
| +// already says whether a retry is safe. |
| +func TestAPISessionRestartServerErrorSurfacesVerbatim(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusTooManyRequests) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"}) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") |
| + if err == nil || !strings.Contains(err.Error(), "wait a minute") { |
| + t.Fatalf("restart 429 = %v, want the console's own text", err) |
| + } |
| +} |
| + |
| +// TestRestartVerbRefusals drives the real binary for the two ways the verb |
| +// stops before it ever dials anything: no -app, and direct bucket mode. Both |
| +// run through carlosTestEnv with a scratch cmd.Dir — the repo rule — so a |
| +// logged-in laptop's real credential and console can never be what this test |
| +// exercises. |
| +func TestRestartVerbRefusals(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + run := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, env...) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + out, err := run(nil, "restart") |
| + if err == nil { |
| + t.Fatalf("restart with no -app exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "usage: carlos restart -app") { |
| + t.Fatalf("restart with no -app = %q, want the usage line", out) |
| + } |
| + |
| + // Direct bucket mode: refused in restart's OWN words, naming the login |
| + // that fixes it — never another verb's advice. |
| + out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "restart", "-app", "keymail") |
| + if err == nil { |
| + t.Fatalf("restart in direct bucket mode exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos restart works through a console") || |
| + !strings.Contains(out, "carlos auth login") { |
| + t.Fatalf("direct-mode refusal = %q, want restart's own message and the login fix", out) |
| + } |
| + |
| + // A malshaped app name is refused at the terminal, before the console's |
| + // shared write budget is spent on a request it is certain to reject. |
| + out, err = run(nil, "restart", "-app", "Not An App") |
| + if err == nil { |
| + t.Fatalf("restart with a bad app name exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "not a usable app name") { |
| + t.Fatalf("bad app name = %q, want the name gate", out) |
| + } |
| +} |