| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostbox" |
| 12 | "github.com/carlosframework/platform/internal/hostwrite" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/rollout" |
| 15 | ) |
| 16 | |
| 17 | // activationLockName is the lock install-control and its siblings hold in |
| 18 | // the directory they install into (activationLock). |
| 19 | const activationLockName = ".claims-activation.lock" |
| 20 | |
| 21 | // claimGlobalSharedPaths is the empty-installation census's classification |
| 22 | // of what the box's own installers put under the managed roots (host |
| 23 | // lifecycle claim P6-D2: shared release directories, shared unit templates |
| 24 | // and the registry database are not host-owned). Each entry is derived from |
| 25 | // the installer that writes it: bootstrap's embedded unit set and arming |
| 26 | // plan, the self-update applier's opt layout and the release store's |
| 27 | // version layout. Nothing here is a wildcard. Per-host live links, unit |
| 28 | // instances, drop-ins and env files stay unclassified, so the census |
| 29 | // refuses them. |
| 30 | func claimGlobalSharedPaths(r ClaimRuntimeResources) map[string][]string { |
| 31 | return map[string][]string{ |
| 32 | "data": claimDataCensus(r.DataDir), |
| 33 | "opt": claimOptCensus(r.OptDir), |
| 34 | "units": claimUnitCensus(r.UnitsDir), |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // claimCensusRel names path below root, or "" (which the census refuses as |
| 39 | // a malformed classification) when it is not strictly inside it. |
| 40 | func claimCensusRel(root, path string) string { |
| 41 | rel, err := filepath.Rel(root, path) |
| 42 | if err != nil || !filepath.IsLocal(rel) { |
| 43 | return "" |
| 44 | } |
| 45 | return filepath.ToSlash(rel) |
| 46 | } |
| 47 | |
| 48 | func claimDataCensus(dataDir string) []string { |
| 49 | // The claim journals and guard records verify their own contents; the |
| 50 | // census reads neither. The registry is bootstrap's file, shared by |
| 51 | // every host, plus SQLite's sidecars once something has opened it. |
| 52 | reg := claimCensusRel(dataDir, filepath.Join(dataDir, "registry.db")) |
| 53 | // Region HA's relocation journals (N9) verify their own contents, like |
| 54 | // the claim's: install and attempt journals, release positions and the |
| 55 | // build's scratch, every one keyed by op id under relocate/ and swept |
| 56 | // once its operation is over. No installation artifact lives there. |
| 57 | // Moves' held-stage relay (N11, until moves ask M-K25 classifies it): |
| 58 | // requests and acks are keyed by host, validated field by field by |
| 59 | // root against the claim before any effect, and never an installation |
| 60 | // artifact. A relocation target whose stage is blocked leaves its |
| 61 | // request there (M3). |
| 62 | stage := claimCensusRel(dataDir, release.HeldStageDir(dataDir)) |
| 63 | staged := claimCensusRel(dataDir, release.HeldStagedDir(dataDir)) |
| 64 | // And its held-stage lock (Task 8c fix round 1, I1): every held |
| 65 | // publication, root's relay and the relocation install's own, holds |
| 66 | // .moves/held-<host>.lock (withHeldStageLock). Moves removes it when the |
| 67 | // stage finishes (M-K25 on #236), but a holder killed mid-stage leaves |
| 68 | // it, so the fallback stays, as moves asked: .moves is a shared tree, |
| 69 | // not opaque: regular files and directories only, and anything naming |
| 70 | // the host being censused still refuses. |
| 71 | entries := []string{".hostclaims", ".hostexec", "hostguard", "relocate", stage, staged, hostbox.CensusTree(movesDirName), hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")} |
| 72 | return append(entries, claimBoxDataCensus(dataDir)...) |
| 73 | } |
| 74 | |
| 75 | // claimBoxDataCensus is the box's own state under the data root, found on a |
| 76 | // serving box (leo, 2026-10-19) and owned by no installation (step 8 |
| 77 | // readiness, R2 (b) and (c)). Each entry is the exact path its writer |
| 78 | // uses, a regular file only, except the edge's certificate cache, which is a |
| 79 | // shared tree of directories and regular files. Nothing here names a host, |
| 80 | // and the per-host census still refuses any path that names the host it |
| 81 | // censuses, so a certificate for that host still refuses its install. |
| 82 | func claimBoxDataCensus(dataDir string) []string { |
| 83 | rel := func(path string) string { return claimCensusRel(dataDir, path) } |
| 84 | files := []string{ |
| 85 | // The edge's autocert cache: certificates and keys, the ACME account |
| 86 | // key (edge.go's -acme-cache default; bootstrap makes the directory). |
| 87 | hostbox.CensusTree(rel(filepath.Join(dataDir, "acme"))), |
| 88 | // The agent's and edge's pointer-digest ratchet (#253): one mark per |
| 89 | // channel, <acct>/<app>/<channel>.digests. Box anti-rollback state, |
| 90 | // never cleared and never an installation artifact (#324). |
| 91 | hostbox.CensusTree(rel(release.EdgeRatchetDir(dataDir))), |
| 92 | // The agent's system update state: its request (requested.json, also |
| 93 | // a file below) and its cache of the router build it fetched, |
| 94 | // <version>/carlos (systemCachePath). Root walks the cache beneath |
| 95 | // the data dir and checks it against the release's signed sums before |
| 96 | // it uses it; a normal update leaves it until the agent's next hourly |
| 97 | // observe forgets it, so a migration window can meet it (harness, |
| 98 | // 2026-10-20). Box state, never an installation artifact. |
| 99 | hostbox.CensusTree(rel(systemDataDir(dataDir))), |
| 100 | } |
| 101 | for _, path := range []string{ |
| 102 | filepath.Join(dataDir, stageRequestMarker), // the agent's root stage request |
| 103 | filepath.Join(dataDir, systemRequestMarker), // the agent's self-update request |
| 104 | systemRequestPath(dataDir), // the update the agent asked for |
| 105 | restartMarksPath(dataDir), // root's restart memory |
| 106 | restartMarksPath(dataDir) + ".lock", // and its lock (lockRestartMarks) |
| 107 | adoptPassLockPath(dataDir), // every root adoption pass's lock |
| 108 | filepath.Join(dataDir, rollout.FailureFile), // the swap failure record |
| 109 | release.FirstSeenPath(dataDir), // channel first-seen observations |
| 110 | release.GenerationsPath(dataDir), // zero-downtime generations |
| 111 | release.GenerationsSeqPath(release.GenerationsPath(dataDir)), |
| 112 | // The edge's GeoIP mirror pointer (edge.go newGeoMirror's directory, |
| 113 | // geoipmirror.go's "current") and the metrics loop's alert state. |
| 114 | filepath.Join(dataDir, "carlos", "geoip", "current"), |
| 115 | filepath.Join(dataDir, "monitor", "alerts.json"), |
| 116 | } { |
| 117 | files = append(files, hostbox.CensusFile(rel(path))) |
| 118 | } |
| 119 | return files |
| 120 | } |
| 121 | |
| 122 | func claimOptCensus(optDir string) []string { |
| 123 | entries := []string{ |
| 124 | // The staged platform binary and the self-update applier's fallback |
| 125 | // with its sidecars. |
| 126 | hostbox.CensusFile(claimCensusRel(optDir, systemBinaryPath(optDir))), |
| 127 | hostbox.CensusFile(claimCensusRel(optDir, systemPrevPath(optDir))), |
| 128 | hostbox.CensusFile(claimCensusRel(optDir, prevVersionPath(optDir))), |
| 129 | hostbox.CensusFile(claimCensusRel(optDir, prevAllowedPath(optDir))), |
| 130 | // The seeds floor's verdict on carlos.prev (seedsfloor.go), beside |
| 131 | // the moves verdict and written by the same root passes. Its floor |
| 132 | // files live in .moves, the shared tree above. |
| 133 | hostbox.CensusFile(claimCensusRel(optDir, seedsPrevAllowedPath(optDir))), |
| 134 | // The shared release store: <opt>/versions/<ver>/[<acct>/]<app>. |
| 135 | // Directories and regular files only; a symlink in it is refused. |
| 136 | hostbox.CensusTree(claimCensusRel(optDir, filepath.Dir(filepath.Dir(release.VersionPath(optDir, "", "v", "a"))))), |
| 137 | // Root's pointer-digest ratchet (#253), the same marks as the edge's |
| 138 | // under the data root; root reads both, so neither is moved aside. |
| 139 | hostbox.CensusTree(claimCensusRel(optDir, release.RootRatchetDir(optDir))), |
| 140 | } |
| 141 | // Root's self-update state and the host-floor guard copy. Both are box |
| 142 | // state below one parent; it is opaque to the census, as before. |
| 143 | for _, dir := range []string{systemRootDir(optDir), systemGuardDir(optDir)} { |
| 144 | if rel := claimCensusRel(optDir, filepath.Dir(dir)); !slices.Contains(entries, rel) { |
| 145 | entries = append(entries, rel) |
| 146 | } |
| 147 | } |
| 148 | // <opt>/live itself is only a directory. Every link in it belongs to a |
| 149 | // host and is unclassified. |
| 150 | return entries |
| 151 | } |
| 152 | |
| 153 | // claimCensusEnabledUnits is every unit an installer enables: bootstrap's |
| 154 | // arming plan (which a self-update apply also arms, from the target build's |
| 155 | // own plan) and ensureSystemUnits' second belt. |
| 156 | func claimCensusEnabledUnits() []string { |
| 157 | units := currentBootstrapPlan().Enabled |
| 158 | for _, u := range ensureSystemArmed { |
| 159 | if !slices.Contains(units, u) { |
| 160 | units = append(units, u) |
| 161 | } |
| 162 | } |
| 163 | return units |
| 164 | } |
| 165 | |
| 166 | func claimUnitCensus(unitsDir string) []string { |
| 167 | names, err := unitNames() |
| 168 | if err != nil { |
| 169 | // Nothing of the platform's is classified, so the census refuses. |
| 170 | return nil |
| 171 | } |
| 172 | var entries []string |
| 173 | for _, name := range names { |
| 174 | // Every embedded unit, templates, slices and mounts included, is a |
| 175 | // file bootstrap and the applier install for the whole box. |
| 176 | entries = append(entries, hostbox.CensusFile(name)) |
| 177 | } |
| 178 | for _, unit := range []string{"carlos-edge.service", "carlos-system-update.service", "carlos-system-recover.service"} { |
| 179 | entries = append(entries, hostbox.CensusFile(unit+".d/90-host-floor.conf")) |
| 180 | } |
| 181 | // install-control's lock (activationLock), left in the unit directory |
| 182 | // after it runs. It stays: unlinking a flocked file lets a second |
| 183 | // installer lock a fresh inode beside the first. |
| 184 | entries = append(entries, hostbox.CensusFile(activationLockName)) |
| 185 | for _, unit := range claimCensusEnabledUnits() { |
| 186 | if !slices.Contains(names, unit) || strings.Contains(unit, "@") { |
| 187 | continue |
| 188 | } |
| 189 | body, err := unitsFS.ReadFile("units/" + unit) |
| 190 | if err != nil { |
| 191 | continue |
| 192 | } |
| 193 | target := filepath.Join(unitsDir, unit) |
| 194 | for _, link := range claimUnitInstallLinks(unit, body) { |
| 195 | entries = append(entries, hostbox.CensusLink(link, target)) |
| 196 | } |
| 197 | } |
| 198 | if hostwrite.Enabled(os.Getenv) && slices.Contains(names, claimControlUnit) { |
| 199 | // Claim rollout uses add-wants to start the static root service at |
| 200 | // boot. Its unit has no [Install] metadata to derive this link from. |
| 201 | entries = append(entries, hostbox.CensusLink("multi-user.target.wants/"+claimControlUnit, filepath.Join(unitsDir, claimControlUnit))) |
| 202 | } |
| 203 | return entries |
| 204 | } |
| 205 | |
| 206 | // claimUnitInstallLinks lists the symlinks `systemctl enable` creates for a |
| 207 | // non-template unit from its [Install] section, relative to the unit |
| 208 | // directory. A value it cannot read exactly (a specifier, an Also= list) is |
| 209 | // left out, so the link it would have made stays unclassified. |
| 210 | func claimUnitInstallLinks(unit string, body []byte) []string { |
| 211 | var links []string |
| 212 | section := "" |
| 213 | scan := bufio.NewScanner(bytes.NewReader(body)) |
| 214 | for scan.Scan() { |
| 215 | line := strings.TrimSpace(scan.Text()) |
| 216 | if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { |
| 217 | continue |
| 218 | } |
| 219 | if strings.HasPrefix(line, "[") { |
| 220 | section = line |
| 221 | continue |
| 222 | } |
| 223 | key, value, ok := strings.Cut(line, "=") |
| 224 | if section != "[Install]" || !ok { |
| 225 | continue |
| 226 | } |
| 227 | suffix := map[string]string{"WantedBy": ".wants/", "RequiredBy": ".requires/", "UpheldBy": ".upholds/", "Alias": ""} |
| 228 | dir, known := suffix[strings.TrimSpace(key)] |
| 229 | if !known { |
| 230 | continue |
| 231 | } |
| 232 | for _, name := range strings.Fields(value) { |
| 233 | if strings.ContainsAny(name, "%/\\") { |
| 234 | continue |
| 235 | } |
| 236 | if dir == "" { |
| 237 | links = append(links, name) |
| 238 | continue |
| 239 | } |
| 240 | links = append(links, name+dir+unit) |
| 241 | } |
| 242 | } |
| 243 | return links |
| 244 | } |
| 245 | |