| 1 | // Package caps: the converge pass (spec §3c, §4b, §4d, §4e). |
| 2 | // |
| 3 | // Converge is the only thing in this repo that writes root-owned systemd |
| 4 | // unit text, so every rail it carries is here rather than spread across its |
| 5 | // callers: the three-state rule, the marker check, the box-wide claims, the |
| 6 | // DynamicUser-flip refusal, and the socket restart ordering. Its only |
| 7 | // contact with the outside world is through Ops, so its tests touch no |
| 8 | // systemd, no /etc, no /run and no /var/lib. |
| 9 | package caps |
| 10 | |
| 11 | import ( |
| 12 | "bytes" |
| 13 | "errors" |
| 14 | "fmt" |
| 15 | "os" |
| 16 | "os/exec" |
| 17 | "os/user" |
| 18 | "path/filepath" |
| 19 | "sort" |
| 20 | "strconv" |
| 21 | "strings" |
| 22 | ) |
| 23 | |
| 24 | const ( |
| 25 | // DefaultEtcDir is where systemd reads unit configuration; DefaultStateRoot |
| 26 | // is the parent of the template's StateDirectory=carlos-app/%i; DefaultRunDir |
| 27 | // is the parent of RuntimeDirectory=<name>. All three are overridable ONLY |
| 28 | // so tests can point them at a t.TempDir() — nothing in a manifest or a |
| 29 | // registry row ever reaches them. |
| 30 | DefaultEtcDir = "/etc/systemd/system" |
| 31 | DefaultStateRoot = "/var/lib/carlos-app" |
| 32 | DefaultRunDir = "/run" |
| 33 | |
| 34 | // DropInName is the one generated file per unit. `10-` sorts before an |
| 35 | // operator's `90-operator.conf`, which this package never writes and never |
| 36 | // reads (spec §3c). |
| 37 | DropInName = "10-carlos-capabilities.conf" |
| 38 | ) |
| 39 | |
| 40 | // Ops is every piece of the outside world this package touches, injected so |
| 41 | // the converge tests need no systemd, no root, and no /etc — the same seam |
| 42 | // shape activator.UnitBackend.Systemctl uses. |
| 43 | type Ops struct { |
| 44 | Authorize func() error |
| 45 | |
| 46 | CheckHost func(string) error |
| 47 | EtcDir string // "" = DefaultEtcDir |
| 48 | StateRoot string // "" = DefaultStateRoot |
| 49 | RunDir string // "" = DefaultRunDir |
| 50 | Systemctl func(args ...string) error // nil = the real systemctl binary |
| 51 | Query func(args ...string) bool // nil = the real systemctl; exit 0 = yes |
| 52 | EnsureUser func(name string) error // nil = the real, idempotent useradd |
| 53 | Logf func(format string, a ...any) // nil = fmt.Printf |
| 54 | } |
| 55 | |
| 56 | // Plan is one route's converge input. |
| 57 | type Plan struct { |
| 58 | Host string |
| 59 | App string |
| 60 | Set *Set // NIL means the manifest did not resolve — LEAVE THE DROP-INS ALONE |
| 61 | Grants Grants |
| 62 | Hibernate bool |
| 63 | |
| 64 | // DeferRestart says the CALLER will start this route's service itself, |
| 65 | // after this converge returns, so nothing here may bring it up. |
| 66 | // |
| 67 | // It exists for exactly one caller: release.adoptHost's version-change |
| 68 | // converge, which runs BEFORE the live symlink moves. Without it a daemon |
| 69 | // version change cycles the unit twice, and the first cycle runs the OLD |
| 70 | // binary under the NEW unit shape — a boot of a combination nobody |
| 71 | // shipped, on a mail server, for the width of a symlink swap. With it the |
| 72 | // service stays down across the swap and comes up exactly once, on the |
| 73 | // new binary, through Restart. |
| 74 | // |
| 75 | // It only ever affects a DAEMON plan's service verbs (step 8's start, |
| 76 | // step 9's restart, and socketSequence's trailing start). A non-daemon |
| 77 | // route's service is not started here with or without it, and the socket |
| 78 | // unit's own verbs are never deferred — a listener the caller is not |
| 79 | // going to re-bind must be re-bound here. |
| 80 | // |
| 81 | // The grant-drift pass (host.capabilityPass) leaves this FALSE and must: |
| 82 | // restarting the live binary to pick up a new unit shape is the entire |
| 83 | // point of that pass, and nothing there follows up with a Restart. |
| 84 | DeferRestart bool |
| 85 | } |
| 86 | |
| 87 | // Snapshot is what was on disk before Converge wrote, enough to put it back. |
| 88 | // |
| 89 | // A half whose file exists WITHOUT the marker is recorded as "not had": this |
| 90 | // pass never wrote it, so putting it back is not this pass's business — and |
| 91 | // Restore is marker-checked too, so a "not had" half can never turn into the |
| 92 | // deletion of somebody's hand-written drop-in. |
| 93 | // |
| 94 | // Daemon travels with the snapshot because putting the BYTES back is only |
| 95 | // half of putting the box back: a restored ListenStream that systemd never |
| 96 | // re-binds restores nothing, and re-binding it needs the same stop → restart |
| 97 | // → start ordering Converge uses, which needs to know whether the service is |
| 98 | // supposed to be running. See Restore. |
| 99 | type Snapshot struct { |
| 100 | Host string |
| 101 | Service, Socket []byte |
| 102 | ServiceHad, SocketHad bool |
| 103 | Daemon bool |
| 104 | } |
| 105 | |
| 106 | // Result is what Converge did. |
| 107 | type Result struct { |
| 108 | Prev Snapshot |
| 109 | Changed bool // any byte written or removed; the caller need not daemon-reload, Converge already did |
| 110 | } |
| 111 | |
| 112 | // ServiceUnit and SocketUnit are the two per-instance units the template |
| 113 | // family instantiates for a route host. |
| 114 | func ServiceUnit(host string) string { return "carlos-app@" + host + ".service" } |
| 115 | func SocketUnit(host string) string { return "carlos-app@" + host + ".socket" } |
| 116 | |
| 117 | // DropInPaths returns the two generated file paths for a host. etcDir "" is |
| 118 | // DefaultEtcDir. The host is NOT validated here — every caller that joins |
| 119 | // these paths validates it first with validHost; this function is the naming |
| 120 | // rule alone, so tests and advisories can name a path without re-deriving it. |
| 121 | func DropInPaths(etcDir, host string) (service, socket string) { |
| 122 | if etcDir == "" { |
| 123 | etcDir = DefaultEtcDir |
| 124 | } |
| 125 | return filepath.Join(etcDir, ServiceUnit(host)+".d", DropInName), |
| 126 | filepath.Join(etcDir, SocketUnit(host)+".d", DropInName) |
| 127 | } |
| 128 | |
| 129 | // Converge materializes p and returns what it replaced. |
| 130 | // |
| 131 | // The three states are NOT interchangeable, and this is the same rule |
| 132 | // appconfig.Sync uses for exactly the same reason: a bucket hiccup must not |
| 133 | // strip :25 off a running mail server. |
| 134 | // - p.Set == nil (absent or unreadable staged manifest): leave every drop-in |
| 135 | // exactly as it is, log one line, return Changed=false, nil error. |
| 136 | // - p.Set non-nil but INVALID for this binary (a capability name or a "v" |
| 137 | // it does not know — reachable via a platform-binary rollback onto an |
| 138 | // already-staged version, since ensureStaged short-circuits on a staged |
| 139 | // artifact and never re-reads its manifest): same leave-alone posture, |
| 140 | // plus an error, so a version-change caller refuses the version instead |
| 141 | // of pointing live at something it cannot materialize. |
| 142 | // - p.Set non-nil, renders bytes: write them if they differ. |
| 143 | // - p.Set non-nil, renders nil: REMOVE the generated file (marker-checked). |
| 144 | // |
| 145 | // Two different kinds of error come back, and they are not the same claim: |
| 146 | // |
| 147 | // - a REFUSAL (every check above, plus the claims and state-dir guards) is |
| 148 | // Changed=false with nothing written, having already logged one line |
| 149 | // naming the operator's fix. Callers that merely survey the box (the |
| 150 | // hourly grant-drift pass) can log-and-continue; callers on the |
| 151 | // version-change path let it refuse the version. |
| 152 | // - a mid-pass IO or systemd FAILURE (a write, a daemon-reload, the socket |
| 153 | // sequence) rolls the bytes back to Prev so that "the bytes on disk say |
| 154 | // converged" stays a sound invariant — if systemd never loaded them, they |
| 155 | // must not be on disk claiming it did, or the next pass would find no |
| 156 | // diff and never retry. Changed is then false, because the disk is back |
| 157 | // where it started. It is true only when even the roll-back failed, which |
| 158 | // is the honest answer: bytes moved and this pass could not undo it. |
| 159 | // |
| 160 | // Failures AFTER a successful reload — an `enable`, a `start`, a daemon's |
| 161 | // `restart` — deliberately do NOT roll the bytes back. systemd has the new |
| 162 | // configuration and it is correct; the failure is the app's. Rolling back |
| 163 | // there would flap the unit text between two shapes on every pass, and the |
| 164 | // version-change caller already owns that case (it rolls back the symlink and |
| 165 | // calls Restore). |
| 166 | // |
| 167 | // That hand-off has a signal, and a caller that ignores it leaves the NEW |
| 168 | // version's unit shape on disk under the OLD binary forever — the next pass |
| 169 | // finds no diff, snapshots those bytes as Prev, and "restores" them: |
| 170 | // |
| 171 | // Changed == true together with a non-nil error means BYTES ARE STILL MOVED |
| 172 | // and this function is not going to move them back. |
| 173 | // |
| 174 | // A version-change caller must answer that with Restore(o, res.Prev). Changed |
| 175 | // == false with an error is a refusal (or a roll-back this function already |
| 176 | // performed) and needs nothing: Prev is then either empty — because the |
| 177 | // refusal came before the drop-ins were even read — or already on disk. Do |
| 178 | // not call Restore unconditionally on a refusal; an empty Prev says "this |
| 179 | // route had no generated drop-ins", and Restore honours that by REMOVING the |
| 180 | // ones it finds. |
| 181 | // |
| 182 | // Concurrency, stated rather than solved: two converges in one process (the |
| 183 | // hourly RunAdopt and the version-change converge inside Pass) are |
| 184 | // sequential. `carlos adopt` run by hand while carlos-adopt.timer fires is |
| 185 | // two processes, and there the atomic rename makes the FILE safe — a reader |
| 186 | // sees old or new bytes, never a torn file — but NOT the systemctl sequence: |
| 187 | // two passes could interleave a stop and a start. The repo already accepts |
| 188 | // exactly this for appconfig.Sync, and the failure mode is a unit that ends |
| 189 | // the minute running the right unit text, so it is accepted here too. |
| 190 | func Converge(o Ops, p Plan) (Result, error) { |
| 191 | var res Result |
| 192 | if o.CheckHost != nil { |
| 193 | if err := o.CheckHost(p.Host); err != nil { |
| 194 | return res, err |
| 195 | } |
| 196 | } |
| 197 | res.Prev.Host = p.Host |
| 198 | if err := o.authorizeMutation(); err != nil { |
| 199 | return res, err |
| 200 | } |
| 201 | |
| 202 | // The host becomes a filename under /etc/systemd/system, written as |
| 203 | // root. Same guard, same reason, same trust level as appconfig.syncHost's |
| 204 | // at :293 — a registry row is trusted, but not to place a root-owned file |
| 205 | // wherever it likes. |
| 206 | if err := validHost(p.Host); err != nil { |
| 207 | o.logf(" %q: %v", p.Host, err) |
| 208 | return res, err |
| 209 | } |
| 210 | svcPath, sockPath := DropInPaths(o.etcDir(), p.Host) |
| 211 | |
| 212 | if p.Set == nil { |
| 213 | // State two of three. NOT a removal: a capability is only ever |
| 214 | // removed because a manifest that ACTUALLY RESOLVED no longer |
| 215 | // declares it, or because a grant was revoked. |
| 216 | o.logf(" %s: no capability manifest resolved this pass — leaving its drop-ins exactly as they are", p.Host) |
| 217 | return res, nil |
| 218 | } |
| 219 | |
| 220 | // The staged manifest is re-validated here even though ship and adopt |
| 221 | // already gate it: ensureStaged returns early when the artifact is |
| 222 | // already on disk, so a ROLLED-BACK platform binary can meet a staged |
| 223 | // version whose capability block it does not understand. This is the |
| 224 | // backstop, and it falls the same way as an unreadable manifest. |
| 225 | if err := ValidCapabilities(p.App, p.Set); err != nil { |
| 226 | o.logf(" %s: %v; leaving its drop-ins exactly as they are", p.Host, err) |
| 227 | return res, err |
| 228 | } |
| 229 | |
| 230 | // A daemon the platform is asked to keep running and to stop when idle is |
| 231 | // a contradiction, and hibernation for these apps means an MX that sleeps. |
| 232 | if p.Set.Daemon && p.Hibernate { |
| 233 | err := fmt.Errorf("capability daemon and hibernation contradict") |
| 234 | o.logf(" %s: %v — nothing written and no unit state changed; fix it with: carlos route --host %s --hibernate=false", p.Host, err, p.Host) |
| 235 | return res, err |
| 236 | } |
| 237 | |
| 238 | hostRole := p.Set.HostRole && p.Grants.HostRole |
| 239 | res.Prev.Daemon = p.Set.Daemon |
| 240 | |
| 241 | // Not a refusal — a granted port on a hibernating route is a legitimate |
| 242 | // shape (socket activation is the wake-on-connect half of hibernation) — |
| 243 | // but it is a wake path that does NOT go through the activator, so the |
| 244 | // edge's health gate does not cover it: a broken boot answers a connection |
| 245 | // on :25 by parking it in the backlog rather than by returning a clean |
| 246 | // 503. One line per pass, for as long as the combination exists, because |
| 247 | // the operator who granted the port did not necessarily choose this. |
| 248 | if p.Hibernate { |
| 249 | for _, tok := range grantedPortTokens(p) { |
| 250 | o.logf(" %s: %s is granted on a hibernating route — a connection to that port starts the unit directly, bypassing the activator's health-gated wake; the edge's clean 503 on a broken boot does not cover this path", p.Host, tok) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // Read both halves BEFORE any refusal that needs to know what this route |
| 255 | // already claims, and before anything is written. Nothing here changes |
| 256 | // state, so an early read costs a refusal nothing. |
| 257 | curSvc, svcExists, svcGenerated, err := readDropIn(svcPath) |
| 258 | if err != nil { |
| 259 | o.logf(" %s: read %s: %v", p.Host, svcPath, err) |
| 260 | return res, err |
| 261 | } |
| 262 | curSock, sockExists, sockGenerated, err := readDropIn(sockPath) |
| 263 | if err != nil { |
| 264 | o.logf(" %s: read %s: %v", p.Host, sockPath, err) |
| 265 | return res, err |
| 266 | } |
| 267 | if svcExists && svcGenerated { |
| 268 | res.Prev.Service, res.Prev.ServiceHad = curSvc, true |
| 269 | } |
| 270 | if sockExists && sockGenerated { |
| 271 | res.Prev.Socket, res.Prev.SocketHad = curSock, true |
| 272 | } |
| 273 | |
| 274 | if err := o.checkStateDirShape(p, hostRole, curSvc, svcExists && svcGenerated); err != nil { |
| 275 | return res, err |
| 276 | } |
| 277 | if err := o.checkClaimsOnDisk(p, curSvc, svcExists && svcGenerated); err != nil { |
| 278 | return res, err |
| 279 | } |
| 280 | |
| 281 | // The user must exist before a User= line naming it does: the unit would |
| 282 | // fail to start and Restart=always would spin it. |
| 283 | if hostRole { |
| 284 | user := AppUser(p.App) |
| 285 | if err := o.ensureUser(user); err != nil { |
| 286 | o.logf(" %s: create system user %s: %v — refusing the whole plan rather than write a User= line for a user that does not exist", p.Host, user, err) |
| 287 | return res, err |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | svcBody, err := RenderService(p.App, p.Set, p.Grants) |
| 292 | if err != nil { |
| 293 | o.logf(" %s: render service drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err) |
| 294 | return res, err |
| 295 | } |
| 296 | sockBody, err := RenderSocket(p.Set, p.Grants) |
| 297 | if err != nil { |
| 298 | o.logf(" %s: render socket drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err) |
| 299 | return res, err |
| 300 | } |
| 301 | |
| 302 | svcChanged, svcErr := o.materialize(p.Host, svcPath, svcBody, curSvc, svcExists, svcGenerated) |
| 303 | var sockChanged bool |
| 304 | var sockErr error |
| 305 | if svcErr == nil { |
| 306 | sockChanged, sockErr = o.materialize(p.Host, sockPath, sockBody, curSock, sockExists, sockGenerated) |
| 307 | } |
| 308 | res.Changed = svcChanged || sockChanged |
| 309 | if err := errors.Join(svcErr, sockErr); err != nil { |
| 310 | // A half-written pass is the divergence this whole rollback exists to |
| 311 | // prevent: one unit converged, the other not, and the bytes on disk |
| 312 | // claiming both are done. Put them back so the next pass sees a diff |
| 313 | // and retries the WHOLE change. |
| 314 | res.Changed = o.rollbackBytes(res.Prev, res.Changed) |
| 315 | return res, err |
| 316 | } |
| 317 | |
| 318 | // systemd reads nothing from disk until it is told to. One reload, and |
| 319 | // only when something actually changed — this is what makes an unchanged |
| 320 | // pass free. It happens BEFORE any restart or start below, without |
| 321 | // exception: a restart against configuration systemd has not re-read is a |
| 322 | // pass that looks like it worked and changed nothing. |
| 323 | if res.Changed { |
| 324 | if err := o.systemctl("daemon-reload"); err != nil { |
| 325 | o.logf(" %s: daemon-reload: %v", p.Host, err) |
| 326 | res.Changed = o.rollbackBytes(res.Prev, res.Changed) |
| 327 | return res, err |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | sockLive := sockBody != nil |
| 332 | st := o.applyUnitState(p, sockLive, svcChanged, sockChanged) |
| 333 | if st.err != nil { |
| 334 | if st.diverged { |
| 335 | // The socket sequence is the one place where systemd's running |
| 336 | // state can end up disagreeing with bytes that read "correct". |
| 337 | // Roll them back so the next pass retries rather than concluding |
| 338 | // there is nothing to do. |
| 339 | res.Changed = o.rollbackBytes(res.Prev, res.Changed) |
| 340 | } |
| 341 | return res, st.err |
| 342 | } |
| 343 | if res.Changed { |
| 344 | what := "capability drop-ins converged" |
| 345 | if st.restarted { |
| 346 | what += " and the unit restarted" |
| 347 | } |
| 348 | o.logf(" %s: %s", p.Host, what) |
| 349 | } |
| 350 | return res, nil |
| 351 | } |
| 352 | |
| 353 | // rollbackBytes puts both halves back to what this pass found, WITHOUT |
| 354 | // touching unit state — the caller is already in a failure path and the point |
| 355 | // is only to stop the disk from claiming a change systemd never took. It |
| 356 | // reports whether any byte is still moved afterwards, which is what Converge |
| 357 | // returns as Changed: false when the disk is back where it started, true when |
| 358 | // even the roll-back failed. |
| 359 | func (o Ops) rollbackBytes(prev Snapshot, changed bool) bool { |
| 360 | if !changed { |
| 361 | return false |
| 362 | } |
| 363 | svcChanged, sockChanged, errs := o.applyHalves(prev) |
| 364 | if len(errs) > 0 { |
| 365 | o.logf(" %s: could not roll the capability drop-ins back after a failed pass (%v) — the next pass will find them mid-change", prev.Host, errors.Join(errs...)) |
| 366 | return true |
| 367 | } |
| 368 | if svcChanged || sockChanged { |
| 369 | if err := o.systemctl("daemon-reload"); err != nil { |
| 370 | o.logf(" %s: daemon-reload after roll-back: %v", prev.Host, err) |
| 371 | } |
| 372 | o.logf(" %s: rolled the capability drop-ins back to what this pass found, so the next pass retries the whole change", prev.Host) |
| 373 | } |
| 374 | return false |
| 375 | } |
| 376 | |
| 377 | // applyHalves writes a Snapshot's bytes over whatever is on disk now — the |
| 378 | // shared body of Restore and rollbackBytes. Marker-checked in both |
| 379 | // directions, exactly as Converge is: a half that was not ours when the |
| 380 | // snapshot was taken is not ours to write over or delete now either. |
| 381 | func (o Ops) applyHalves(want Snapshot) (svcChanged, sockChanged bool, errs []error) { |
| 382 | svcPath, sockPath := DropInPaths(o.etcDir(), want.Host) |
| 383 | halves := []struct { |
| 384 | path string |
| 385 | body []byte |
| 386 | had bool |
| 387 | out *bool |
| 388 | }{ |
| 389 | {svcPath, want.Service, want.ServiceHad, &svcChanged}, |
| 390 | {sockPath, want.Socket, want.SocketHad, &sockChanged}, |
| 391 | } |
| 392 | for _, half := range halves { |
| 393 | body := half.body |
| 394 | if !half.had { |
| 395 | body = nil |
| 396 | } |
| 397 | cur, exists, generated, err := readDropIn(half.path) |
| 398 | if err != nil { |
| 399 | o.logf(" %s: read %s: %v", want.Host, half.path, err) |
| 400 | errs = append(errs, err) |
| 401 | continue |
| 402 | } |
| 403 | did, err := o.materialize(want.Host, half.path, body, cur, exists, generated) |
| 404 | if err != nil { |
| 405 | errs = append(errs, err) |
| 406 | } |
| 407 | *half.out = did |
| 408 | } |
| 409 | return svcChanged, sockChanged, errs |
| 410 | } |
| 411 | |
| 412 | // grantedPortTokens is the declared-AND-granted port set, as canonical grant |
| 413 | // tokens, sorted — the ports that actually materialize. |
| 414 | func grantedPortTokens(p Plan) []string { |
| 415 | var toks []string |
| 416 | for _, decl := range p.Set.Ports { |
| 417 | if p.Grants.Ports[decl.Port] { |
| 418 | toks = append(toks, PortToken(decl.Port)) |
| 419 | } |
| 420 | } |
| 421 | sort.Strings(toks) |
| 422 | return toks |
| 423 | } |
| 424 | |
| 425 | // unitState is what applyUnitState did and how badly it failed. diverged is |
| 426 | // the distinction that matters: a failed SOCKET SEQUENCE means systemd's |
| 427 | // running listener no longer matches the bytes on disk, and only the caller |
| 428 | // rolling those bytes back makes the next pass retry. |
| 429 | type unitState struct { |
| 430 | restarted bool |
| 431 | diverged bool |
| 432 | err error |
| 433 | } |
| 434 | |
| 435 | // repaired announces a drift repair — a verb this pass issued because |
| 436 | // systemd's RUNNING state disagreed with what the route declares, not because |
| 437 | // any byte changed. |
| 438 | // |
| 439 | // It is deliberately not folded into Converge's trailing "converged" line, |
| 440 | // which is gated on res.Changed: the repairs below fire on passes where |
| 441 | // nothing was written at all, and reporting them there made the most |
| 442 | // surprising thing this pass does — starting a unit an operator stopped by |
| 443 | // hand ten minutes ago to debug it — completely silent. Whoever stopped that |
| 444 | // unit needs the hourly log to say which verb ran and why. |
| 445 | // |
| 446 | // Only a repair that SUCCEEDED is announced; a failed one is already reported |
| 447 | // by applyUnitState's error line, and "started it" is not a claim to make |
| 448 | // about a start that did not. |
| 449 | func (o Ops) announceRepair(host string, err error, format string, a ...any) { |
| 450 | if err != nil { |
| 451 | return |
| 452 | } |
| 453 | o.logf(" %s: %s", host, fmt.Sprintf(format, a...)) |
| 454 | } |
| 455 | |
| 456 | // applyUnitState is steps 7-9: the socket restart ordering, the enable/start |
| 457 | // a daemon route's drift repair needs, and the service restart. Every verb |
| 458 | // here runs AFTER Converge's daemon-reload, never before it. |
| 459 | // |
| 460 | // p.DeferRestart suppresses every verb that would BRING A DAEMON UP — step |
| 461 | // 8's start, step 9's restart, and socketSequence's trailing start — and |
| 462 | // nothing else. The socket unit's own verbs, the enables, and the service |
| 463 | // STOP still happen: a listener has to be re-bound here because the caller |
| 464 | // will not do it, and leaving the service down across the caller's symlink |
| 465 | // swap is the entire point. See Plan.DeferRestart. |
| 466 | func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) unitState { |
| 467 | svcUnit, sockUnit := ServiceUnit(p.Host), SocketUnit(p.Host) |
| 468 | var st unitState |
| 469 | note := func(err error) { |
| 470 | if err != nil && st.err == nil { |
| 471 | st.err = err |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | restartedService := false |
| 476 | |
| 477 | // Step 7. A running service holds the bound fd, so restarting the socket |
| 478 | // unit under it races into EADDRINUSE and the MX comes back unbound. |
| 479 | // |
| 480 | // This runs when the socket drop-in was REMOVED as well as when it was |
| 481 | // rewritten: systemd is still holding the listener the removed file asked |
| 482 | // for, and "a grant is not a promise" (spec §7) means revoking port:25 |
| 483 | // has to actually drop :25 — a rewrite nobody reloads into a running |
| 484 | // socket unit is a revoke that did nothing. |
| 485 | if sockChanged { |
| 486 | err := o.socketSequence(p.Host, p.Set.Daemon, p.DeferRestart, "restart") |
| 487 | note(err) |
| 488 | if err != nil { |
| 489 | st.diverged = true |
| 490 | } |
| 491 | restartedService = true |
| 492 | } |
| 493 | |
| 494 | // Step 8. `systemctl enable` writes the boot-time .wants symlink and |
| 495 | // bootstrap does not enable tenant socket units — without this the |
| 496 | // ListenStream binds nothing after a reboot. Both verbs are gated on a |
| 497 | // query rather than run unconditionally, so a converged box's hourly pass |
| 498 | // changes no state at all (which is what makes "an unchanged pass does |
| 499 | // nothing" true for a daemon route, not just for a quiet one). |
| 500 | if sockLive { |
| 501 | if !o.query("is-enabled", sockUnit) { |
| 502 | err := o.systemctl("enable", sockUnit) |
| 503 | note(err) |
| 504 | o.announceRepair(p.Host, err, "%s was not enabled — enabled it, or its ListenStream binds nothing after a reboot", sockUnit) |
| 505 | } |
| 506 | if !o.query("is-active", sockUnit) && !restartedService { |
| 507 | // Bringing an inactive socket up goes through the same ordering: |
| 508 | // the service may be holding the port itself today (keymail binds |
| 509 | // :25 until its LISTEN_FDS change lands), and a bare socket start |
| 510 | // under it is the EADDRINUSE race again. |
| 511 | err := o.socketSequence(p.Host, p.Set.Daemon, p.DeferRestart, "start") |
| 512 | note(err) |
| 513 | o.announceRepair(p.Host, err, "%s was not active — started it through the stop → socket → start ordering, so nothing races the service for the port", sockUnit) |
| 514 | restartedService = true |
| 515 | } |
| 516 | } |
| 517 | if p.Set.Daemon { |
| 518 | if !o.query("is-enabled", svcUnit) { |
| 519 | err := o.systemctl("enable", svcUnit) |
| 520 | note(err) |
| 521 | o.announceRepair(p.Host, err, "%s was not enabled — enabled it, so it comes back after a reboot", svcUnit) |
| 522 | } |
| 523 | if !o.query("is-active", svcUnit) && !p.DeferRestart { |
| 524 | // enable+start, not try-restart: a daemon that crashed into a |
| 525 | // stopped state comes back rather than being left down by a |
| 526 | // try-restart no-op (spec §4d). |
| 527 | // |
| 528 | // Deferred, this is the start that would run the OLD binary: the |
| 529 | // service is inactive here precisely because step 7 just stopped |
| 530 | // it, and the caller's Restart is one symlink swap away. |
| 531 | err := o.systemctl("start", svcUnit) |
| 532 | note(err) |
| 533 | o.announceRepair(p.Host, err, "the service was not running and this route declares daemon — started it") |
| 534 | restartedService = true |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | // Step 9. A non-daemon route's restart is not this function's business: |
| 539 | // the caller's existing try-restart owns it, byte-identically to today. |
| 540 | // Nor is a DEFERRED daemon's: restarting it here would cycle the old |
| 541 | // binary under the new unit text, and the caller restarts it once, after |
| 542 | // the symlink moves. |
| 543 | if svcChanged && p.Set.Daemon && !restartedService && !p.DeferRestart { |
| 544 | note(o.systemctl("restart", svcUnit)) |
| 545 | restartedService = true |
| 546 | } |
| 547 | st.restarted = restartedService |
| 548 | if st.err != nil { |
| 549 | o.logf(" %s: unit state: %v", p.Host, st.err) |
| 550 | } |
| 551 | return st |
| 552 | } |
| 553 | |
| 554 | // socketSequence is the pinned ordering: stop the service, restart (or |
| 555 | // start) the socket, start the service. |
| 556 | // |
| 557 | // What the ordering buys, stated precisely so nobody reads more into it than |
| 558 | // it gives: while the SERVICE is down and the socket unit is up, inbound |
| 559 | // connections queue in the kernel backlog and are answered when the service |
| 560 | // comes back — that is the phase spec §4c's "an MX restart never refuses a |
| 561 | // connection" is about, and it is strictly better than today, where a relay |
| 562 | // restart drops :25 entirely. During the socket unit's OWN restart the |
| 563 | // listener does not exist, and connections in that window are refused, not |
| 564 | // queued. The window is one systemctl verb wide and only opens when the |
| 565 | // rendered ListenStream set actually changed. |
| 566 | // |
| 567 | // The service start is ATTEMPTED even when the socket verb failed. Returning |
| 568 | // early there would leave the service stopped — trading a socket that did not |
| 569 | // rebind for an app that is not running at all — so the error is remembered |
| 570 | // and returned, and the start happens anyway. |
| 571 | // |
| 572 | // Accepted, and theoretical until keymail's LISTEN_FDS change lands: between |
| 573 | // the stop and the socket verb the socket unit may still be up, so a |
| 574 | // connection can socket-ACTIVATE the service back before this function starts |
| 575 | // it — after which the start is systemd's no-op. That is a correct end state |
| 576 | // (the service is running against the reloaded configuration), just not one |
| 577 | // this function drove; it is not worth an isolate/mask dance to prevent. |
| 578 | // |
| 579 | // A route that is neither a daemon nor currently running is neither stopped |
| 580 | // nor started: a hibernating tenant's drop-ins are unit text, not a wake. |
| 581 | // |
| 582 | // deferStart drops the trailing service start for a DAEMON whose caller is |
| 583 | // about to start it itself (Plan.DeferRestart) — the service is then stopped |
| 584 | // here and stays stopped until the caller's Restart, which is what keeps the |
| 585 | // old binary from ever running under the new unit shape. It is deliberately |
| 586 | // ignored for a non-daemon route: there the caller's follow-up is a |
| 587 | // try-restart, which is a no-op on a stopped unit, so skipping the start |
| 588 | // would leave the app down. |
| 589 | func (o Ops) socketSequence(host string, daemon, deferStart bool, verb string) error { |
| 590 | svcUnit, sockUnit := ServiceUnit(host), SocketUnit(host) |
| 591 | shouldRun := daemon || o.query("is-active", svcUnit) |
| 592 | |
| 593 | var errs []error |
| 594 | if shouldRun { |
| 595 | if err := o.systemctl("stop", svcUnit); err != nil { |
| 596 | errs = append(errs, err) |
| 597 | } |
| 598 | } |
| 599 | if err := o.systemctl(verb, sockUnit); err != nil { |
| 600 | errs = append(errs, err) |
| 601 | } |
| 602 | if shouldRun && !(daemon && deferStart) { |
| 603 | if err := o.systemctl("start", svcUnit); err != nil { |
| 604 | errs = append(errs, err) |
| 605 | } |
| 606 | } |
| 607 | return errors.Join(errs...) |
| 608 | } |
| 609 | |
| 610 | // materialize writes body (or removes the file when body is nil) and reports |
| 611 | // whether any byte moved. A file at a generated path WITHOUT the marker on |
| 612 | // its first line is never overwritten and never deleted: it is reported once |
| 613 | // per pass and this half is skipped entirely, while the other half still |
| 614 | // converges. |
| 615 | func (o Ops) materialize(host, path string, body, cur []byte, exists, generated bool) (bool, error) { |
| 616 | if exists && !generated { |
| 617 | o.logf(" %s: %s exists but does not carry the %q marker — hand-edited or written by something else; never overwritten, never removed, skipping this unit's drop-in", host, path, Marker) |
| 618 | return false, nil |
| 619 | } |
| 620 | if body == nil { |
| 621 | if !exists { |
| 622 | return false, nil |
| 623 | } |
| 624 | if err := os.Remove(path); err != nil { |
| 625 | o.logf(" %s: remove %s: %v", host, path, err) |
| 626 | return false, err |
| 627 | } |
| 628 | o.logf(" %s: no capability materializes here any more — removed %s", host, path) |
| 629 | return true, nil |
| 630 | } |
| 631 | if exists && bytes.Equal(cur, body) { |
| 632 | return false, nil // up to date: no write, no reload, no restart |
| 633 | } |
| 634 | if err := writeFileAtomic(path, body); err != nil { |
| 635 | o.logf(" %s: write %s: %v", host, path, err) |
| 636 | return false, err |
| 637 | } |
| 638 | return true, nil |
| 639 | } |
| 640 | |
| 641 | // writeFileAtomic creates the .d directory and renames a temp file into |
| 642 | // place, so a concurrent reader (systemd, or a second adopt process) sees |
| 643 | // either the old bytes or the new ones and never a torn file. Root-owned |
| 644 | // 0644, set explicitly rather than left to the process umask, because these |
| 645 | // bytes are read by systemd and their mode is part of the contract. |
| 646 | func writeFileAtomic(path string, body []byte) error { |
| 647 | dir := filepath.Dir(path) |
| 648 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 649 | return err |
| 650 | } |
| 651 | tmp := path + ".tmp" |
| 652 | if err := os.WriteFile(tmp, body, 0o644); err != nil { |
| 653 | return err |
| 654 | } |
| 655 | if err := os.Chmod(tmp, 0o644); err != nil { |
| 656 | os.Remove(tmp) |
| 657 | return err |
| 658 | } |
| 659 | if err := os.Rename(tmp, path); err != nil { |
| 660 | os.Remove(tmp) |
| 661 | return err |
| 662 | } |
| 663 | return nil |
| 664 | } |
| 665 | |
| 666 | // readDropIn returns a generated file's bytes, whether it exists at all, and |
| 667 | // whether it carries the marker. |
| 668 | func readDropIn(path string) (b []byte, exists, generated bool, err error) { |
| 669 | b, err = os.ReadFile(path) |
| 670 | if err != nil { |
| 671 | if os.IsNotExist(err) { |
| 672 | return nil, false, false, nil |
| 673 | } |
| 674 | return nil, false, false, err |
| 675 | } |
| 676 | return b, true, bytes.HasPrefix(b, []byte(Marker+"\n")), nil |
| 677 | } |
| 678 | |
| 679 | // checkStateDirShape is spec §4b's pinned sharp edge. With DynamicUser=yes |
| 680 | // systemd materialises StateDirectory under /var/lib/private/ with a SYMLINK |
| 681 | // at /var/lib/carlos-app/<host>; with a static User= it expects a real |
| 682 | // directory. Flipping between the two under an existing state dir is refused |
| 683 | // in place — the operator does the stop → move → start, because moving a |
| 684 | // live app's data is not a thing an hourly timer should do unattended. |
| 685 | // |
| 686 | // The OFF direction is deliberately narrow: it refuses only when THIS |
| 687 | // package's own current drop-in is what put the unit on a static user. A |
| 688 | // pre-existing real directory that no drop-in of ours created (a legacy |
| 689 | // hand-provisioned console, for example) is not a flip and must not freeze an |
| 690 | // unrelated route's capabilities forever. |
| 691 | func (o Ops) checkStateDirShape(p Plan, hostRole bool, curSvc []byte, generated bool) error { |
| 692 | dir := filepath.Join(o.stateRoot(), p.Host) |
| 693 | fi, err := os.Lstat(dir) |
| 694 | if err != nil { |
| 695 | return nil // absent is fine in both directions |
| 696 | } |
| 697 | wasStatic := generated && dropInHas(curSvc, "DynamicUser=no") |
| 698 | switch { |
| 699 | case hostRole && fi.Mode()&os.ModeSymlink != 0: |
| 700 | e := fmt.Errorf("host_role wants a static user but %s is a symlink (the DynamicUser shape, real data under /var/lib/private)", dir) |
| 701 | o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s && mv /var/lib/private/carlos-app/%s %s && rm the symlink && chown -R %s %s && systemctl start %s", |
| 702 | p.Host, e, ServiceUnit(p.Host), p.Host, dir, AppUser(p.App), dir, ServiceUnit(p.Host)) |
| 703 | return e |
| 704 | case !hostRole && wasStatic && fi.IsDir(): |
| 705 | e := fmt.Errorf("dropping host_role returns the unit to DynamicUser, which cannot use the existing real directory %s", dir) |
| 706 | o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s, move the data under /var/lib/private/carlos-app/%s, then start it", |
| 707 | p.Host, e, ServiceUnit(p.Host), p.Host) |
| 708 | return e |
| 709 | } |
| 710 | return nil |
| 711 | } |
| 712 | |
| 713 | // checkClaimsOnDisk resolves the two box-wide exclusivities against what is |
| 714 | // ALREADY WRITTEN on this box, before anything is written here. CheckClaims |
| 715 | // covers a whole pass's plans at once; this covers the single-route |
| 716 | // version-change path, which never sees the other routes — and it is the only |
| 717 | // one of the two that notices a drop-in left behind by a route that has since |
| 718 | // been removed from the registry. |
| 719 | // |
| 720 | // The /run existence check is the second guard on runtime_dir. The static |
| 721 | // reservedRuntimeDir list is necessarily finite — a box-specific |
| 722 | // /run/tailscale is not on it and never will be — so a name that already |
| 723 | // exists under /run and is not this route's own prior claim is refused, |
| 724 | // whatever the list says. |
| 725 | func (o Ops) checkClaimsOnDisk(p Plan, curSvc []byte, generated bool) error { |
| 726 | ports, dirs := scanClaims(o.etcDir(), p.Host) |
| 727 | |
| 728 | for _, decl := range p.Set.Ports { |
| 729 | if !p.Grants.Ports[decl.Port] { |
| 730 | continue // an ungranted port materializes nothing and claims nothing |
| 731 | } |
| 732 | if other, taken := ports[decl.Port]; taken { |
| 733 | e := fmt.Errorf("port %d is already claimed by %s's drop-in on this box", decl.Port, other) |
| 734 | o.logf(" %s: %v — refusing before anything is written; :%d can be held by exactly one process on one machine", p.Host, e, decl.Port) |
| 735 | return e |
| 736 | } |
| 737 | } |
| 738 | |
| 739 | if p.Set.RuntimeDir == "" { |
| 740 | return nil |
| 741 | } |
| 742 | if other, taken := dirs[p.Set.RuntimeDir]; taken { |
| 743 | e := fmt.Errorf("runtime_dir %q is already claimed by %s's drop-in on this box", p.Set.RuntimeDir, other) |
| 744 | o.logf(" %s: %v — refusing before anything is written; a shared /run directory is another app's sockets", p.Host, e) |
| 745 | return e |
| 746 | } |
| 747 | ownClaim := generated && dropInHas(curSvc, "RuntimeDirectory="+p.Set.RuntimeDir) |
| 748 | if ownClaim { |
| 749 | return nil |
| 750 | } |
| 751 | runPath := filepath.Join(o.runDir(), p.Set.RuntimeDir) |
| 752 | if _, err := os.Lstat(runPath); err == nil { |
| 753 | e := fmt.Errorf("runtime_dir %q: %s already exists and is not this route's own claim", p.Set.RuntimeDir, runPath) |
| 754 | o.logf(" %s: %v — refusing before anything is written; systemd would hand this app a directory something else on the box already owns", p.Host, e) |
| 755 | return e |
| 756 | } |
| 757 | return nil |
| 758 | } |
| 759 | |
| 760 | // scanClaims reads every OTHER host's drop-in at the generated path on this |
| 761 | // box and returns which privileged ports and which runtime directories they |
| 762 | // claim, mapped to the host that claims them. |
| 763 | // |
| 764 | // A file at that path WITHOUT the marker counts too, and deliberately: it is |
| 765 | // not ours to rewrite, so systemd will apply it, so the claim it makes is |
| 766 | // real. Refusing is the fail-closed direction — the alternative is two units |
| 767 | // racing for :25, which is the exact failure this check exists to prevent. |
| 768 | // (Only the 10- path is read; an operator's 90-operator.conf is theirs, and |
| 769 | // this package neither reads nor reasons about it.) |
| 770 | func scanClaims(etcDir, exceptHost string) (map[int]string, map[string]string) { |
| 771 | ports := map[int]string{} |
| 772 | dirs := map[string]string{} |
| 773 | ents, err := os.ReadDir(etcDir) |
| 774 | if err != nil { |
| 775 | return ports, dirs |
| 776 | } |
| 777 | for _, e := range ents { |
| 778 | if !e.IsDir() { |
| 779 | continue |
| 780 | } |
| 781 | name := e.Name() |
| 782 | host, kind, ok := parseUnitDir(name) |
| 783 | if !ok || host == exceptHost { |
| 784 | continue |
| 785 | } |
| 786 | b, exists, _, err := readDropIn(filepath.Join(etcDir, name, DropInName)) |
| 787 | if err != nil || !exists { |
| 788 | continue |
| 789 | } |
| 790 | for _, line := range strings.Split(string(b), "\n") { |
| 791 | switch kind { |
| 792 | case "socket": |
| 793 | if v, ok := strings.CutPrefix(line, "ListenStream="); ok { |
| 794 | if n, ok := listenStreamPort(v); ok { |
| 795 | ports[n] = host |
| 796 | } |
| 797 | } |
| 798 | case "service": |
| 799 | if v, ok := strings.CutPrefix(line, "RuntimeDirectory="); ok && v != "" { |
| 800 | dirs[v] = host |
| 801 | } |
| 802 | } |
| 803 | } |
| 804 | } |
| 805 | return ports, dirs |
| 806 | } |
| 807 | |
| 808 | // listenStreamPort reads the port out of a ListenStream= value. This package |
| 809 | // only ever WRITES the bare-number form, but the claim scan also reads files |
| 810 | // this package did not write, and systemd accepts several spellings of the |
| 811 | // same bind: "25", "0.0.0.0:25", "[::]:25", "1.2.3.4:25". Taking the segment |
| 812 | // after the last colon covers all of them, so an address form cannot dodge |
| 813 | // the box-wide port claim — the check would be worth little if it could. |
| 814 | // |
| 815 | // A value containing a path separator is a unix socket (the template's own |
| 816 | // ListenStream=/run/carlos/apps/%i.sock) and claims no TCP port at all. |
| 817 | func listenStreamPort(v string) (int, bool) { |
| 818 | v = strings.TrimSpace(v) |
| 819 | if v == "" || strings.Contains(v, "/") { |
| 820 | return 0, false |
| 821 | } |
| 822 | if i := strings.LastIndex(v, ":"); i >= 0 { |
| 823 | v = v[i+1:] |
| 824 | } |
| 825 | n, err := strconv.Atoi(v) |
| 826 | if err != nil || n < 1 || n > 65535 { |
| 827 | return 0, false |
| 828 | } |
| 829 | return n, true |
| 830 | } |
| 831 | |
| 832 | // parseUnitDir splits "carlos-app@<host>.service.d" into its host and kind. |
| 833 | func parseUnitDir(name string) (host, kind string, ok bool) { |
| 834 | rest, ok := strings.CutPrefix(name, "carlos-app@") |
| 835 | if !ok { |
| 836 | return "", "", false |
| 837 | } |
| 838 | for _, k := range []string{"service", "socket"} { |
| 839 | if h, ok := strings.CutSuffix(rest, "."+k+".d"); ok && h != "" { |
| 840 | return h, k, true |
| 841 | } |
| 842 | } |
| 843 | return "", "", false |
| 844 | } |
| 845 | |
| 846 | // dropInHas reports whether body carries the exact directive line. |
| 847 | func dropInHas(body []byte, line string) bool { |
| 848 | for _, l := range strings.Split(string(body), "\n") { |
| 849 | if l == line { |
| 850 | return true |
| 851 | } |
| 852 | } |
| 853 | return false |
| 854 | } |
| 855 | |
| 856 | // CheckClaims resolves box-wide exclusivity BEFORE anything is written, |
| 857 | // across every plan on this box at once: a privileged port claimed by two |
| 858 | // routes, and a runtime_dir claimed by two routes. Returns host -> refusal; a |
| 859 | // host absent from the map cleared. |
| 860 | // |
| 861 | // Deterministic on ties: the route whose Host sorts first keeps the claim and |
| 862 | // the other is refused, so a conflict does not flap between passes — two |
| 863 | // routes taking turns owning :25 would restart a mail server every hour |
| 864 | // forever. |
| 865 | // |
| 866 | // A plan whose Set is nil claims nothing: it is the leave-alone state, and |
| 867 | // leaving a drop-in alone is not a new claim. An ungranted port likewise |
| 868 | // claims nothing, because nothing materializes for it. |
| 869 | // |
| 870 | // Known limit, named rather than papered over: this tie-break is decided |
| 871 | // against the PLANS, and Converge's own check is decided against what is |
| 872 | // already on DISK. When the lexicographically-LATER host is the one already |
| 873 | // materialized, the two disagree — CheckClaims refuses the later host, and |
| 874 | // Converge then refuses the earlier host too, because the later host's |
| 875 | // drop-in really is holding the port. The result is a stable both-refused |
| 876 | // stall, not a flap: neither route materializes and both log every pass until |
| 877 | // an operator revokes one grant. That is the fail-closed direction (nobody |
| 878 | // silently takes :25 off a running mail server), and the alternative — |
| 879 | // letting the winner evict the incumbent's drop-in — is a pass that stops |
| 880 | // somebody else's listener to satisfy a sort order. |
| 881 | func CheckClaims(plans []Plan) map[string]error { |
| 882 | sorted := make([]Plan, len(plans)) |
| 883 | copy(sorted, plans) |
| 884 | sort.Slice(sorted, func(i, j int) bool { return sorted[i].Host < sorted[j].Host }) |
| 885 | |
| 886 | refused := map[string]error{} |
| 887 | ports := map[int]string{} |
| 888 | dirs := map[string]string{} |
| 889 | for _, p := range sorted { |
| 890 | if p.Set == nil || refused[p.Host] != nil { |
| 891 | continue |
| 892 | } |
| 893 | for _, decl := range p.Set.Ports { |
| 894 | if !p.Grants.Ports[decl.Port] { |
| 895 | continue |
| 896 | } |
| 897 | if other, taken := ports[decl.Port]; taken { |
| 898 | refused[p.Host] = fmt.Errorf("port %d is already claimed on this box by %s — :%d can be held by exactly one process on one machine; revoke it from one of the two (carlos route --host %s --revoke %s)", |
| 899 | decl.Port, other, decl.Port, p.Host, PortToken(decl.Port)) |
| 900 | break |
| 901 | } |
| 902 | ports[decl.Port] = p.Host |
| 903 | } |
| 904 | if refused[p.Host] != nil { |
| 905 | continue |
| 906 | } |
| 907 | if p.Set.RuntimeDir == "" { |
| 908 | continue |
| 909 | } |
| 910 | if other, taken := dirs[p.Set.RuntimeDir]; taken { |
| 911 | refused[p.Host] = fmt.Errorf("runtime_dir %q is already claimed on this box by %s — a shared /run directory is another app's sockets; one of the two must declare a different name", |
| 912 | p.Set.RuntimeDir, other) |
| 913 | continue |
| 914 | } |
| 915 | dirs[p.Set.RuntimeDir] = p.Host |
| 916 | } |
| 917 | return refused |
| 918 | } |
| 919 | |
| 920 | // Restore puts prev back — the drop-in half of adoptHost's restart-failure |
| 921 | // rollback (the symlink half is release.PointLive's). |
| 922 | // |
| 923 | // The SERVICE half's restart is the caller's: it rolled the symlink back and |
| 924 | // it owns bringing the app up on the old version. The SOCKET half's is NOT. |
| 925 | // Bytes are not a binding: a restored ListenStream that systemd never re-binds |
| 926 | // restores nothing — the MX stays off :25 after a rollback — and a rolled-back |
| 927 | // ADDED port stays publicly bound to a binary that never asked for it. So when |
| 928 | // the socket half's bytes move, Restore runs the same stop → restart → start |
| 929 | // sequence Converge does, which is why Snapshot carries Daemon. |
| 930 | // |
| 931 | // Marker-checked in both directions, exactly as Converge is: a half that was |
| 932 | // not ours when the snapshot was taken is not ours to write over or delete |
| 933 | // now either. |
| 934 | func Restore(o Ops, prev Snapshot) error { |
| 935 | if err := o.authorizeMutation(); err != nil { |
| 936 | return err |
| 937 | |
| 938 | } |
| 939 | if o.CheckHost != nil { |
| 940 | if err := o.CheckHost(prev.Host); err != nil { |
| 941 | return err |
| 942 | } |
| 943 | } |
| 944 | if err := validHost(prev.Host); err != nil { |
| 945 | o.logf(" %q: %v", prev.Host, err) |
| 946 | return err |
| 947 | } |
| 948 | svcChanged, sockChanged, errs := o.applyHalves(prev) |
| 949 | if svcChanged || sockChanged { |
| 950 | // Before any unit verb, without exception. |
| 951 | if err := o.systemctl("daemon-reload"); err != nil { |
| 952 | o.logf(" %s: daemon-reload: %v", prev.Host, err) |
| 953 | errs = append(errs, err) |
| 954 | } |
| 955 | o.logf(" %s: capability drop-ins rolled back to the previous version's", prev.Host) |
| 956 | } |
| 957 | if sockChanged { |
| 958 | // Never deferred: a restore is the end of the line, and its caller |
| 959 | // has already rolled the symlink back to the binary this shape |
| 960 | // belongs to. |
| 961 | if err := o.socketSequence(prev.Host, prev.Daemon, false, "restart"); err != nil { |
| 962 | o.logf(" %s: rebinding the previous version's listeners: %v", prev.Host, err) |
| 963 | errs = append(errs, err) |
| 964 | } |
| 965 | } |
| 966 | return errors.Join(errs...) |
| 967 | } |
| 968 | |
| 969 | // Restart is the daemon route's restart: `systemctl restart`, and its error |
| 970 | // is REAL — it is what makes adoptHost's rollback fire. Non-daemon routes are |
| 971 | // not this function's business: rootRestartRoute's try-restart keeps owning them, |
| 972 | // byte-identically. |
| 973 | func Restart(o Ops, p Plan) error { |
| 974 | if err := o.authorizeMutation(); err != nil { |
| 975 | return err |
| 976 | |
| 977 | } |
| 978 | if o.CheckHost != nil { |
| 979 | if err := o.CheckHost(p.Host); err != nil { |
| 980 | return err |
| 981 | } |
| 982 | } |
| 983 | if err := validHost(p.Host); err != nil { |
| 984 | o.logf(" %q: %v", p.Host, err) |
| 985 | return err |
| 986 | } |
| 987 | return o.systemctl("restart", ServiceUnit(p.Host)) |
| 988 | } |
| 989 | |
| 990 | // validHost refuses anything that is not a bare hostname before it is joined |
| 991 | // into a root-written /etc/systemd/system path — the same guard, in the same |
| 992 | // direction, as appconfig.syncHost's. |
| 993 | func validHost(host string) error { |
| 994 | if host == "" || host == "." || host == ".." || host != filepath.Base(host) { |
| 995 | return fmt.Errorf("not a bare hostname, refusing to derive a /etc/systemd/system path from it") |
| 996 | } |
| 997 | return nil |
| 998 | } |
| 999 | |
| 1000 | func (o Ops) etcDir() string { |
| 1001 | if o.EtcDir == "" { |
| 1002 | return DefaultEtcDir |
| 1003 | } |
| 1004 | return o.EtcDir |
| 1005 | } |
| 1006 | |
| 1007 | func (o Ops) stateRoot() string { |
| 1008 | if o.StateRoot == "" { |
| 1009 | return DefaultStateRoot |
| 1010 | } |
| 1011 | return o.StateRoot |
| 1012 | } |
| 1013 | |
| 1014 | func (o Ops) runDir() string { |
| 1015 | if o.RunDir == "" { |
| 1016 | return DefaultRunDir |
| 1017 | } |
| 1018 | return o.RunDir |
| 1019 | } |
| 1020 | |
| 1021 | func (o Ops) logf(format string, a ...any) { |
| 1022 | if o.Logf != nil { |
| 1023 | o.Logf(format, a...) |
| 1024 | return |
| 1025 | } |
| 1026 | fmt.Printf(format+"\n", a...) |
| 1027 | } |
| 1028 | |
| 1029 | // systemctl runs one state-CHANGING verb. Every call here is something a |
| 1030 | // converged box does not need: an unchanged pass makes none of them, which is |
| 1031 | // why the tests can assert "zero calls" and mean it. |
| 1032 | func (o Ops) systemctl(args ...string) error { |
| 1033 | if o.Systemctl != nil { |
| 1034 | return o.Systemctl(args...) |
| 1035 | } |
| 1036 | out, err := exec.Command("systemctl", args...).CombinedOutput() |
| 1037 | if err != nil { |
| 1038 | return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) |
| 1039 | } |
| 1040 | return nil |
| 1041 | } |
| 1042 | |
| 1043 | // query runs a systemctl QUERY verb (`is-active`, `is-enabled`) and reports |
| 1044 | // whether it answered yes. It is a separate seam from Systemctl on purpose: a |
| 1045 | // query changes nothing, and the properties this package promises — an |
| 1046 | // unchanged pass writes no bytes, reloads nothing and restarts nothing — are |
| 1047 | // about state CHANGES. Folding the two together would force a choice between |
| 1048 | // repairing drift (an unenabled socket unit, a daemon that is not running, |
| 1049 | // both named in spec §4d as what this pass fixes) and being quiet when there |
| 1050 | // is no drift; with the seams split, both hold. |
| 1051 | // |
| 1052 | // A systemctl that cannot run at all answers "no", which errs toward |
| 1053 | // attempting an idempotent enable/start rather than silently skipping the |
| 1054 | // repair. |
| 1055 | func (o Ops) query(args ...string) bool { |
| 1056 | if o.Query != nil { |
| 1057 | return o.Query(args...) |
| 1058 | } |
| 1059 | return exec.Command("systemctl", args...).Run() == nil |
| 1060 | } |
| 1061 | |
| 1062 | // ensureUser creates the locked, home-less, nologin system user a host_role |
| 1063 | // app runs as, idempotently — exactly as host.ensureServiceUser creates |
| 1064 | // `carlos`, and with the name DERIVED by AppUser, never taken from a manifest. |
| 1065 | func (o Ops) ensureUser(name string) error { |
| 1066 | if o.EnsureUser != nil { |
| 1067 | return o.EnsureUser(name) |
| 1068 | } |
| 1069 | if _, err := user.Lookup(name); err == nil { |
| 1070 | return nil |
| 1071 | } |
| 1072 | out, err := exec.Command("useradd", "--system", "--user-group", |
| 1073 | "--no-create-home", "--shell", "/usr/sbin/nologin", name).CombinedOutput() |
| 1074 | if err != nil { |
| 1075 | return fmt.Errorf("create user %s: %v: %s", name, err, strings.TrimSpace(string(out))) |
| 1076 | } |
| 1077 | // useradd is not the only way this can fail: a NSS backend that accepted |
| 1078 | // the create but does not yet resolve the name would leave a User= line |
| 1079 | // pointing at nothing. Prove the account is resolvable before saying yes. |
| 1080 | if _, err := user.Lookup(name); err != nil { |
| 1081 | return fmt.Errorf("created user %s but it does not resolve: %v", name, err) |
| 1082 | } |
| 1083 | return nil |
| 1084 | } |
| 1085 | |