package release import ( "context" "crypto/ed25519" "fmt" "os" "github.com/carlosframework/platform/internal/registry" ) // MaterializeHost stages host's channel head and points its live symlink — // the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5), // for an instance or a native sidecar (zero-downtime deploys spec §7.9). // It IS the adopt pass, filtered to one host with restart=nil (the host is // cold; the activator execing it is the "restart"): bake holds, signature // verification, staging, and the Version stamp are all the pass's own — // a second staging path is exactly the drift this refuses to create. // // Success is judged by the symlink, not the pass error: PassFilter logs // per-route trouble rather than returning it (only foundation failures — an // unreadable registry, an unwritable bake clock — come back as errors), and // "held by a bake window", "nothing promoted for this channel" and "no such // host" all correctly leave the link absent. A caller about to exec the // binary needs the link, so an absent link is this function's error. // // And judged by a link that RESOLVES, not merely one that exists. The caller // is ExecBackend.ensureLiveLink, which reaches this hook whenever os.Stat of // the link fails — and os.Stat FOLLOWS symlinks, so "link present, staged // version gone" (an operator's /opt cleanup, a partially restored volume) // arrives here exactly like "link absent". The pass cannot repair that state // on its own: with the channel head unchanged, adoptHost's cur == ver // short-circuit sees the dangling link's version via os.Readlink and returns // having staged nothing. So a dangling link is reported as the error it is, // naming the missing target — never as success the caller would answer by // exec'ing a path that does not resolve. // // dataDir is where the bake clock lives (BakeDir; /data in production), so // the window a cold wake observes is the same one the hourly root pass // measures — a cold wake must never become a bake bypass by keeping its own // clock. // // opts are appended to the internal pass's options — the caller's chance to // carry OnStageBlocked, so a cold wake on a hardened box relays its blocked // stage to the root pass instead of just erroring (the agent's // materializeHook does exactly this). BakeDir is always set first and is not // the caller's to override. func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string, opts ...PassOption) error { only := func(rt registry.Route) bool { return rt.Host == host } if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, append([]PassOption{BakeDir(dataDir)}, opts...)...); err != nil { return fmt.Errorf("materialize %s: %w", host, err) } link := LiveLink(optDir, host) target, err := os.Readlink(link) switch { case err != nil && os.IsNotExist(err): return fmt.Errorf("materialize %s: %s", host, noHeadReason(ctx, reg, s, pub, host)) case err != nil: return fmt.Errorf("materialize %s: read live link %s: %w", host, link, err) } // The link is there; stat FOLLOWS it, so this is the question the caller // actually has — is there a binary at the end of it? if _, err := os.Stat(link); err != nil { return fmt.Errorf("materialize %s: live link %s points at %s, which does not resolve (%v) — the staged version was removed; re-promote the channel or run `carlos adopt` on the box", host, link, target, err) } return nil } // noAdoptableHead is what an absent live link means when nothing more // specific can be PROVEN: the channel has no pointer, the pointer would not // verify, or its head is sitting out a bake window. All three leave the same // absence behind and all three are answered the same way — wait, or promote. const noAdoptableHead = "no adoptable head (nothing promoted for its channel, or the head is bake-held)" // StaticHeadUnderInstance is the marker internal/activator matches to render // the one absent-link cause that is neither "wait" nor "promote": a channel // whose head is a STATIC release, under a route that execs a binary. // Exported so the member-facing renderer and this diagnosis cannot drift into // matching on different words. const StaticHeadUnderInstance = "the head is a static release" // noHeadReason explains an absent live link, in the member's terms where it // can and in the generic terms above where it cannot. // // It exists for one case the generic sentence gets actively WRONG. A static // release's manifest keys its artifact "site", not "-", so // ensureStaged refuses to stage it ("no linux-arm64 build — skipping"), the // pass logs that box-side and moves on, and the link is never written. The // member is then told nothing is promoted for the channel while `carlos // channels` shows the promote that plainly did happen — two contradictory // true statements, and no way from either to the actual problem, which is // that an instance route can never serve a static site (skills#29). // // The route class is checked, not assumed: only a Kind=="instance" route // execs a binary, and only for one is the mismatch this names real. // // Two bucket reads, on the failure path only. A wake that already failed can // afford them, and nothing on the success path pays anything. Every step // degrades to the generic reason — a diagnosis that cannot be made must not // become a second, worse error on top of the first. func noHeadReason(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, host string) string { rt, err := reg.Get(host) if err != nil || rt == nil || rt.Kind != "instance" { return noAdoptableHead } acct, channel := RouteAccount(*rt), routeChannel(*rt) ptr, err := Channel(ctx, s, pub, acct, rt.App, channel) if err != nil || ptr == nil || ptr.Version == "" { return noAdoptableHead // genuinely nothing promoted, or a pointer that would not verify } m, err := GetManifest(ctx, s, acct, rt.App, ptr.Version) if err != nil || m == nil || m.Kind != "static" { return noAdoptableHead } return fmt.Sprintf("%s: %s %s is promoted on %s, but a static site is served straight off the "+ "channel pointer and has no binary to run, so this route can never wake into it. "+ "A static site needs a static route: `carlos instances delete --app %s --host %s`, then "+ "`carlos deploy --app %s --kind static --host %s `", StaticHeadUnderInstance, rt.App, ptr.Version, channel, rt.App, host, rt.App, host) }