| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/registry" |
| 10 | ) |
| 11 | |
| 12 | // MaterializeHost stages host's channel head and points its live symlink — |
| 13 | // the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5), |
| 14 | // for an instance or a native sidecar (zero-downtime deploys spec §7.9). |
| 15 | // It IS the adopt pass, filtered to one host with restart=nil (the host is |
| 16 | // cold; the activator execing it is the "restart"): bake holds, signature |
| 17 | // verification, staging, and the Version stamp are all the pass's own — |
| 18 | // a second staging path is exactly the drift this refuses to create. |
| 19 | // |
| 20 | // Success is judged by the symlink, not the pass error: PassFilter logs |
| 21 | // per-route trouble rather than returning it (only foundation failures — an |
| 22 | // unreadable registry, an unwritable bake clock — come back as errors), and |
| 23 | // "held by a bake window", "nothing promoted for this channel" and "no such |
| 24 | // host" all correctly leave the link absent. A caller about to exec the |
| 25 | // binary needs the link, so an absent link is this function's error. |
| 26 | // |
| 27 | // And judged by a link that RESOLVES, not merely one that exists. The caller |
| 28 | // is ExecBackend.ensureLiveLink, which reaches this hook whenever os.Stat of |
| 29 | // the link fails — and os.Stat FOLLOWS symlinks, so "link present, staged |
| 30 | // version gone" (an operator's /opt cleanup, a partially restored volume) |
| 31 | // arrives here exactly like "link absent". The pass cannot repair that state |
| 32 | // on its own: with the channel head unchanged, adoptHost's cur == ver |
| 33 | // short-circuit sees the dangling link's version via os.Readlink and returns |
| 34 | // having staged nothing. So a dangling link is reported as the error it is, |
| 35 | // naming the missing target — never as success the caller would answer by |
| 36 | // exec'ing a path that does not resolve. |
| 37 | // |
| 38 | // dataDir is where the bake clock lives (BakeDir; /data in production), so |
| 39 | // the window a cold wake observes is the same one the hourly root pass |
| 40 | // measures — a cold wake must never become a bake bypass by keeping its own |
| 41 | // clock. |
| 42 | // |
| 43 | // opts are appended to the internal pass's options — the caller's chance to |
| 44 | // carry OnStageBlocked, so a cold wake on a hardened box relays its blocked |
| 45 | // stage to the root pass instead of just erroring (the agent's |
| 46 | // materializeHook does exactly this). BakeDir is always set first and is not |
| 47 | // the caller's to override. |
| 48 | func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string, opts ...PassOption) error { |
| 49 | only := func(rt registry.Route) bool { return rt.Host == host } |
| 50 | if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, append([]PassOption{BakeDir(dataDir)}, opts...)...); err != nil { |
| 51 | return fmt.Errorf("materialize %s: %w", host, err) |
| 52 | } |
| 53 | link := LiveLink(optDir, host) |
| 54 | target, err := os.Readlink(link) |
| 55 | switch { |
| 56 | case err != nil && os.IsNotExist(err): |
| 57 | return fmt.Errorf("materialize %s: %s", host, noHeadReason(ctx, reg, s, pub, host)) |
| 58 | case err != nil: |
| 59 | return fmt.Errorf("materialize %s: read live link %s: %w", host, link, err) |
| 60 | } |
| 61 | // The link is there; stat FOLLOWS it, so this is the question the caller |
| 62 | // actually has — is there a binary at the end of it? |
| 63 | if _, err := os.Stat(link); err != nil { |
| 64 | 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) |
| 65 | } |
| 66 | return nil |
| 67 | } |
| 68 | |
| 69 | // noAdoptableHead is what an absent live link means when nothing more |
| 70 | // specific can be PROVEN: the channel has no pointer, the pointer would not |
| 71 | // verify, or its head is sitting out a bake window. All three leave the same |
| 72 | // absence behind and all three are answered the same way — wait, or promote. |
| 73 | const noAdoptableHead = "no adoptable head (nothing promoted for its channel, or the head is bake-held)" |
| 74 | |
| 75 | // StaticHeadUnderInstance is the marker internal/activator matches to render |
| 76 | // the one absent-link cause that is neither "wait" nor "promote": a channel |
| 77 | // whose head is a STATIC release, under a route that execs a binary. |
| 78 | // Exported so the member-facing renderer and this diagnosis cannot drift into |
| 79 | // matching on different words. |
| 80 | const StaticHeadUnderInstance = "the head is a static release" |
| 81 | |
| 82 | // noHeadReason explains an absent live link, in the member's terms where it |
| 83 | // can and in the generic terms above where it cannot. |
| 84 | // |
| 85 | // It exists for one case the generic sentence gets actively WRONG. A static |
| 86 | // release's manifest keys its artifact "site", not "<goos>-<goarch>", so |
| 87 | // ensureStaged refuses to stage it ("no linux-arm64 build — skipping"), the |
| 88 | // pass logs that box-side and moves on, and the link is never written. The |
| 89 | // member is then told nothing is promoted for the channel while `carlos |
| 90 | // channels` shows the promote that plainly did happen — two contradictory |
| 91 | // true statements, and no way from either to the actual problem, which is |
| 92 | // that an instance route can never serve a static site (skills#29). |
| 93 | // |
| 94 | // The route class is checked, not assumed: only a Kind=="instance" route |
| 95 | // execs a binary, and only for one is the mismatch this names real. |
| 96 | // |
| 97 | // Two bucket reads, on the failure path only. A wake that already failed can |
| 98 | // afford them, and nothing on the success path pays anything. Every step |
| 99 | // degrades to the generic reason — a diagnosis that cannot be made must not |
| 100 | // become a second, worse error on top of the first. |
| 101 | func noHeadReason(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, host string) string { |
| 102 | rt, err := reg.Get(host) |
| 103 | if err != nil || rt == nil || rt.Kind != "instance" { |
| 104 | return noAdoptableHead |
| 105 | } |
| 106 | acct, channel := RouteAccount(*rt), routeChannel(*rt) |
| 107 | ptr, err := Channel(ctx, s, pub, acct, rt.App, channel) |
| 108 | if err != nil || ptr == nil || ptr.Version == "" { |
| 109 | return noAdoptableHead // genuinely nothing promoted, or a pointer that would not verify |
| 110 | } |
| 111 | m, err := GetManifest(ctx, s, acct, rt.App, ptr.Version) |
| 112 | if err != nil || m == nil || m.Kind != "static" { |
| 113 | return noAdoptableHead |
| 114 | } |
| 115 | return fmt.Sprintf("%s: %s %s is promoted on %s, but a static site is served straight off the "+ |
| 116 | "channel pointer and has no binary to run, so this route can never wake into it. "+ |
| 117 | "A static site needs a static route: `carlos instances delete --app %s --host %s`, then "+ |
| 118 | "`carlos deploy --app %s --kind static --host %s <site-dir>`", |
| 119 | StaticHeadUnderInstance, rt.App, ptr.Version, channel, rt.App, host, rt.App, host) |
| 120 | } |
| 121 | |