// The grant-drift capability pass: RunAdopt's step 2 (spec §4d). // // This is the survey half of the capability design. adoptHost's converge // (release.WithCapabilities) is what a VERSION change moves through; this is // what a GRANT change moves through — and, because it converges every // adoptable unit route from the staged manifest of the version that is // ACTUALLY LIVE, it is also the repair path for everything else that can // leave a box's unit shape disagreeing with what the platform believes: // // - a grant added or revoked with `carlos route -grant/-revoke`; // - a drop-in deleted by hand, or lost with an /etc restore; // - a socket unit that is not enabled, or a daemon that is not running; // - a FIRST adoption whose restart failed: the symlink rolled back to // nothing (there was no previous version), so the drop-ins this pass // re-verifies are the live version's and stand as converged; // - any stranded shape left by a pass that died between two writes. // // Both entry points call the same caps.Converge, and running both in one // pass is idempotent: the second sees byte-identical content, writes nothing, // reloads nothing and restarts nothing. package host import ( "fmt" "log" "path/filepath" "sort" "strings" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // capabilityPass converges every adoptable unit route on this box and stamps // what it learned about their databases onto the registry. // // Pure enough to test: the registry, the opt dir and the caps.Ops seams are // all arguments, so its tests need no systemd, no root and no /etc. // // Its error return is for FOUNDATION failures only (the registry would not // list). A per-route refusal — an unreadable manifest, an unparseable grants // column, a port another route already claims — logs and continues: this pass // surveys a whole box, and one misconfigured route must not stop every other // route's drift from being repaired. func capabilityPass(reg *registry.Registry, optDir string, o caps.Ops) error { routes, err := reg.List() if err != nil { return err } // An empty route list is never authority to do anything, exactly as in // appconfig.Sync: registry.Open silently creates an empty database when // handed a wrong path or an unmounted /data, and a pass that believed it // would see every route on the box as removed. Nothing here deletes on // absence today — a drop-in is only ever removed for a route this pass // still sees — but the guard is cheap and keeps that true by construction. if len(routes) == 0 { return nil } plans, advice := buildPlans(routes, stagedManifestOfLiveVersion(optDir)) for _, line := range advice { log.Print(line) } // Box-wide exclusivity across every plan at once, BEFORE anything is // written: two routes granted the same privileged port, or the same // runtime_dir. Converge repeats the check against what is on DISK; this // one is the only half that can see the other plans. refused := caps.CheckClaims(plans) for _, p := range plans { if err := refused[p.Host]; err != nil { log.Printf("adopt: %s: %v — nothing written for it this pass", p.Host, err) continue } // Converge has already logged both a refusal's operator fix and a // failure's cause, in its own house style, so nothing is re-logged // here. DeferRestart stays FALSE (caps.Plan's contract): restarting // the live binary to pick up a new unit shape is the entire point of // this pass, and nothing here follows up with a Restart. // // Both returns are discarded ON PURPOSE, and that is correct FOR THIS // CALLER only. Result.Changed together with an error means bytes are // still moved and Converge will not move them back — which a // version-change caller must answer with caps.Restore, because there // the moved bytes are the NEW version's shape under the OLD binary. // Here there is no version change and no old binary: the bytes moved // are this route's own live shape, which is the steady state this // pass is converging TOWARD, and the next pass simply finds whatever // diff remains and retries it. Adding a Restore here would undo a // correct repair every time a post-reload verb failed. _, _ = caps.Converge(o, p) } stampDBPaths(reg, routes, plans, o) return nil } // stagedManifestOfLiveVersion is the production loader: the staged sibling // manifest of the version a route's live symlink CURRENTLY names. // // The live version, never the newest staged one — a box that has staged a // version it has not adopted (a bake window, a poisoned build, a rollback) // must keep the unit shape of what it is actually running. // // (nil, nil) — no live symlink, or no sibling beside the binary — is the // leave-alone state, and it is deliberately NOT healed here: this pass does // not fetch from the bucket. release.adoptHost's back-fill re-stages the live // version's sibling on every pass, so a sibling that failed to write once // heals there and this pass picks it up next hour. func stagedManifestOfLiveVersion(optDir string) func(registry.Route) (*release.Manifest, error) { return func(rt registry.Route) (*release.Manifest, error) { ver, err := release.LiveVersion(optDir, rt.Host) if err != nil { return nil, err } if ver == "" { return nil, nil } return release.ReadStagedManifest(optDir, release.RouteAccount(rt), ver, rt.App) } } // buildPlans is the pure half: routes plus their live manifests in, converge // plans plus the advisory lines that fall out of reading them out. Extracted // so every word an operator reads in the hourly log is table-testable without // touching disk, systemd or the network. // // Three states, and they are not interchangeable (spec §4d): // // - load returns (nil, nil) — no live version, or no staged sibling for it: // a plan with a NIL Set, which converge reads as "leave every drop-in // exactly as it is" and logs once. Not a removal: a bucket hiccup must // not strip :25 off a running mail server. // - load returns an error (a sibling that exists but does not parse): the // route gets NO plan at all — the same leave-alone outcome — plus one // line naming what could not be read. // - load returns a manifest: a plan whose Set is non-nil even when the // manifest declares nothing, so that a capability DROPPED from a manifest // is actually removed. Manifest.Capabilities is a nil pointer for both a // legacy manifest and a fetch that never happened, so it is never passed // through verbatim. // // An unparseable grants column refuses that route's converge and says so, // rather than defaulting to no grants: "no grants" is a real instruction that // drops :25, and a column this binary cannot read is not evidence that an // operator asked for that. func buildPlans(routes []registry.Route, load func(rt registry.Route) (*release.Manifest, error)) ([]caps.Plan, []string) { var plans []caps.Plan var advice []string for _, rt := range routes { unitRoute := release.CapabilityRoute(rt) // The exec-backed candidate set is warnBackingMismatch's plus one // condition: an instance route that is not a platform alias and is // not unit-backed, AND that names an app — without an app there is no // release, so nothing could declare a capability to advise about. It // materializes nothing (release.CapabilityRoute excludes it) and is // told so once per pass. execCandidate := !unitRoute && rt.Kind == "instance" && rt.App != "" && !rt.Platform && rt.Backing != registry.BackingUnit if !unitRoute && !execCandidate { continue } m, err := load(rt) if err != nil { // Two different consequences, so two different sentences: a unit // route HAS drop-ins that are being left alone, an exec-backed // route has none and never did — telling its operator that // something is being left as it is would describe a file that // does not exist. if unitRoute { advice = append(advice, fmt.Sprintf("adopt: %s: the staged manifest of its live version could not be read (%v) — its capability drop-ins are left exactly as they are until it can be", rt.Host, err)) } else { advice = append(advice, fmt.Sprintf("adopt: %s: the staged manifest of its live version could not be read (%v) — this pass cannot tell whether it declares capabilities; it is exec-backed, so nothing is materialized for it either way", rt.Host, err)) } continue } if execCandidate { if m != nil && m.Capabilities != nil { advice = append(advice, fmt.Sprintf("adopt: route %s declares capabilities but is exec-backed — nothing is materialized; the row has to be stamped backing='unit' before any capability can be written (see the app-capabilities design §5 for the stop → copy → start migration)", rt.Host)) } continue } g, err := caps.ParseGrants(rt.Caps) if err != nil { advice = append(advice, fmt.Sprintf("adopt: %s: %v — refusing to converge this route rather than guess which grants were meant; fix the row with `carlos route --host %s --grant/--revoke` from a known-good state", rt.Host, err, rt.Host)) continue } p := caps.Plan{Host: rt.Host, App: rt.App, Grants: g, Hibernate: rt.Hibernate} if m == nil { // Set stays nil: leave-alone, and converge logs the one line. plans = append(plans, p) continue } p.Set = m.Capabilities if p.Set == nil { p.Set = &caps.Set{V: caps.Version} } plans = append(plans, p) advice = append(advice, ungrantedAdvice(rt, p.Set, g)...) advice = append(advice, undeclaredGrantAdvice(rt, p.Set, g)...) advice = append(advice, dataAdvice(rt, p.Set)...) } return plans, advice } // ungrantedAdvice: one line per (route, operator-consented capability) that // the app DECLARES and this box has not GRANTED. Not a failure and not an // adoption failure — the drop-in simply does not carry it — so the line names // the consequence and the exact command that fixes it. // // Deliberately silent for the self-service capabilities: `command`, `daemon`, // `children`, `runtime_dir` and `data` need no grant, so there is nothing for // an operator to do about them and a line would be noise. On a healthy fleet // (every declared grant granted) this advisory produces nothing at all. func ungrantedAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string { var out []string ports := append([]caps.Port(nil), s.Ports...) sort.Slice(ports, func(i, j int) bool { return ports[i].Port < ports[j].Port }) for _, decl := range ports { if g.Ports[decl.Port] { continue } out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — nothing is bound on :%d; grant it with: carlos route --host %s --grant %s", rt.Host, caps.PortToken(decl.Port), decl.Port, rt.Host, caps.PortToken(decl.Port))) } if s.HostRole && !g.HostRole { out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps DynamicUser and cannot use the box's instance role; grant it with: carlos route --host %s --grant %s", rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken)) } if s.DocEngine && !g.DocEngine { out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps MemoryDenyWriteExecute and the default memory tier, so a Chromium or LibreOffice engine will not start (and would be OOM-killed if it did); grant it with: carlos route --host %s --grant %s", rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken)) } return out } // undeclaredGrantAdvice is ungrantedAdvice's mirror, and the half that was // missing (2026-08-03 review, M5): a token this box HAS granted that the live // release does not declare. // // Not a refusal, and not a security hole — an operator-consented capability // materializes only where the manifest asks for it, so an undeclared grant // writes nothing and reaches nothing. What it IS is a lie in the registry: a // row that reads "this box has handed :25 to keymail" while nothing on the box // binds :25, which is exactly the state an operator looks at during an // incident. It also names the two ways it happens — a grant staged for a // release that was never shipped, and a release that DROPPED a capability it // used to declare, which removes the drop-in and leaves the grant behind. // // Silent for a leave-alone route: this reads p.Set, which the caller only // fills from a manifest it actually read, so a bucket hiccup cannot make this // print "granted but not declared" about a release nobody could see. Sorted // by port for the same reason ungrantedAdvice sorts — an hourly log line whose // order shuffles per pass reads as churn. func undeclaredGrantAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string { declared := make(map[int]bool, len(s.Ports)) for _, p := range s.Ports { declared[p.Port] = true } ports := make([]int, 0, len(g.Ports)) for port := range g.Ports { if !declared[port] { ports = append(ports, port) } } sort.Ints(ports) var out []string for _, port := range ports { out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — nothing is materialized for it; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it", rt.Host, caps.PortToken(port), rt.Host, caps.PortToken(port))) } if g.HostRole && !s.HostRole { out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps DynamicUser and reaches nothing of the box's instance role; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it", rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken)) } if g.DocEngine && !s.DocEngine { out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps the default resource tier and its normal egress; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it", rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken)) } return out } // dataAdvice is what the DBPath stamp cannot say by stamping (spec §6). // // Route.DBPath is single-valued and data.dbs is a list, so only the first // name (sorted) is replicated and the rest are named rather than quietly // dropped. And a route whose app streams its own WAL keeps whatever DBPath an // operator set — the platform never clears it: two replicators on one WAL // fight over checkpointing and corrupt the replica, and deciding which one to // switch off is the operator's call, not an hourly timer's. func dataAdvice(rt registry.Route, s *caps.Set) []string { if s.Data == nil { return nil } var out []string if s.Data.Replicate == caps.DataReplicatePlatform && len(s.Data.DBs) > 1 { for _, extra := range s.Data.DBs[1:] { out = append(out, fmt.Sprintf("adopt: %s declares data.dbs %q, but a route replicates exactly one database (DBPath is single-valued) — %q is NOT replicated by the box-wide litestream unit", rt.Host, extra, extra)) } } if s.Data.Replicate == caps.DataReplicateApp && rt.DBPath != "" { out = append(out, fmt.Sprintf("adopt: %s declares data.replicate=%q — the app streams its own WAL — but its route still names db_path %s, so the box-wide litestream unit replicates it too; two replicators on one WAL fight over checkpointing and corrupt the replica. The platform will not clear the column for you: which replicator stops is an operator decision", rt.Host, caps.DataReplicateApp, rt.DBPath)) } return out } // stampDBPaths is how ops.FleetDBs learns a unit tenant's database, instead // of depending on an operator having typed `-db` at `carlos add` — which the // current code documents as "silently replicated by nobody" (spec §6). // // Only `data.replicate: "platform"` with a non-empty dbs list stamps, only // dbs[0] AS DECLARED, and only when the value would actually change: this // runs hourly, and a write per pass would be a registry heartbeat nobody // asked for. // // dbs[0] as declared, NOT sorted (orchestrator ruling, 2026-08-03, // overruling the plan): an author writing ["console.db", "audit.db"] means // console.db is the one that matters, and lexicographic order would have // replicated audit.db instead — the platform silently picking a different // database than the app named. Declaration order is the app's own statement // of which database is primary; nothing else in the manifest carries it. // // The overwrite rule is the sharp one, and it is deliberately conservative // (2026-08-03 review, I3). An existing NON-EMPTY db_path this pass does not // recognise — anything not already under this box's state root for this host // — is an operator's `-db`, pointing at where the data lives RIGHT NOW. On a // pre-migration route (design §5) that is /data/.db, the file the // box-wide replicator is streaming today; overwriting it with the // post-migration path would point litestream at a file that does not exist // yet and silently stop replicating a live database. So it is advised and // left alone, and the stamp follows the operator's data copy. // // It is deliberately NOT gated on the route's converge succeeding. The state // directory is the unchanged template's own StateDirectory=carlos-app/%i, so // the path is right whether or not a drop-in was written this pass, and a // route refused for an unrelated reason (a port another route already claims) // must still have its database replicated — a capability refusal is not a // reason to stop backing up somebody's data. // // The state root comes from the same Ops the converge used, so a test's temp // dir is stamped rather than a production path a test never wrote to. func stampDBPaths(reg *registry.Registry, routes []registry.Route, plans []caps.Plan, o caps.Ops) { root := o.StateRoot if root == "" { root = caps.DefaultStateRoot } cur := make(map[string]string, len(routes)) for _, rt := range routes { cur[rt.Host] = rt.DBPath } for _, p := range plans { if p.Set == nil || p.Set.Data == nil { continue } d := p.Set.Data if d.Replicate != caps.DataReplicatePlatform || len(d.DBs) == 0 { continue } want := filepath.Join(root, p.Host, d.DBs[0]) from := cur[p.Host] if from == want { continue } if from != "" && !ownStateDirPath(from, root, p.Host) { log.Printf("adopt: %s carries db_path %s, which is not under this box's state directory for it; the manifest expects %s — leaving the operator's value alone, because repointing the replicator at a file the §5 data copy has not created yet would silently stop replicating a live database. The stamp follows the copy", p.Host, from, want) continue } if err := reg.SetDBPath(p.Host, from, want); err != nil { log.Printf("adopt: %s: recording db_path %s: %v", p.Host, want, err) continue } if from == "" { log.Printf("adopt: %s: db_path recorded as %s from the app's data.dbs — the box-wide litestream unit replicates it from the next `carlos ops litestream` run", p.Host, want) } else { log.Printf("adopt: %s: db_path %s -> %s, following the app's data.dbs — the box-wide litestream unit picks the new file up on the next `carlos ops litestream` run", p.Host, from, want) } } } // ownStateDirPath reports whether path is a file this box's own state // directory for host owns — i.e. a value some pass of this function wrote, // or would write. Compared as a cleaned path prefix so a trailing slash or a // "." segment cannot dodge it, and never as a substring: /var/lib/carlos-app/ // -old/x must not read as this host's. func ownStateDirPath(path, root, host string) bool { dir := filepath.Clean(filepath.Join(root, host)) rel, err := filepath.Rel(dir, filepath.Clean(path)) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) }