| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "os/exec" |
| 7 | "path/filepath" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // systemRecoverUnit is carlos-system-recover.service, spelled out once |
| 13 | // rather than repeated as a string literal at each call site below. |
| 14 | const systemRecoverUnit = "carlos-system-recover.service" |
| 15 | |
| 16 | // instanceControlSocket is the root instance-control channel's socket |
| 17 | // (design §5.2). ensureSystemUnits arms it the same way it arms the |
| 18 | // self-update units: this pass is the second belt for any unit a roll |
| 19 | // script forgot, and a box with the socket unarmed has no isolated-exec |
| 20 | // control channel at all. |
| 21 | const instanceControlSocket = "carlos-instance-control.socket" |
| 22 | |
| 23 | // ensureSystemArmed is what ensureSystemUnits arms (systemctlPlan: enable, |
| 24 | // and start for the path unit and the sockets); carlos-dbread.socket only |
| 25 | // where host.env arms db reads (dbReadsSocketWanted). The claim census reads |
| 26 | // the same list (claimCensusEnabledUnits), so a unit armed here always has |
| 27 | // its install links classified there. |
| 28 | var ensureSystemArmed = []string{systemUpdatePathUnit, systemRecoverUnit, instanceControlSocket, dbReadSocketUnit} |
| 29 | |
| 30 | // carlos-dbread.socket and its service template are the db-read worker's |
| 31 | // (db region forward spec §8.3): installed and the socket armed here, the |
| 32 | // template never started. |
| 33 | var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit, instanceControlSocket, "carlos-instance-control.service", dbReadSocketUnit, "carlos-dbread@.service"} |
| 34 | |
| 35 | // ensureSystemUnits is the second belt of spec §7: a box the roll script |
| 36 | // forgot to arm is armed within the hour by the root adopt pass. It |
| 37 | // installs the embedded units when missing and arms them through |
| 38 | // systemctlPlan (never starting a service). Non-fatal: it runs before |
| 39 | // RunAdopt's registry and pubkey gates, and logs instead of exiting. |
| 40 | // |
| 41 | // It writes into the same <etc>/systemd/system unit files that `carlos |
| 42 | // system apply`'s installUnits copies, using the same durable-write |
| 43 | // staging name — so it takes the apply's own lock, non-blocking, before |
| 44 | // touching anything. An apply in flight wins; this pass leaves it alone |
| 45 | // rather than risk tearing a unit file mid-copy. lockPath is the apply's |
| 46 | // lock file (systemRootDir(release.OptDir())/lock in production; a temp |
| 47 | // path in tests). |
| 48 | // |
| 49 | // Two systemctl seams, because the two uses want opposite things from an |
| 50 | // exit status: systemctl runs the arming COMMANDS, where non-zero is a |
| 51 | // failure to report, and probe asks the two QUESTIONS, where non-zero is |
| 52 | // half the answer (see the block below). |
| 53 | func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...string) (string, error), logf func(string, ...any)) { |
| 54 | if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { |
| 55 | logf("adopt: system units: %v", err) |
| 56 | return |
| 57 | } |
| 58 | lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) |
| 59 | if err != nil { |
| 60 | logf("adopt: system units: %v", err) |
| 61 | return |
| 62 | } |
| 63 | defer lock.Close() |
| 64 | // Any error here — real contention, or errSystemUnsupported on the |
| 65 | // windows build that has no flock — means don't touch the units: an |
| 66 | // apply may be mid-copy, or this platform can't tell, and either way |
| 67 | // leaving it alone is the safe default. The lock's cadence takes care |
| 68 | // of trying again next tick. |
| 69 | if err := flockExclusiveNonblock(lock); err != nil { |
| 70 | logf("adopt: system units: an apply is running; leaving the self-update units to it") |
| 71 | return |
| 72 | } |
| 73 | |
| 74 | claimSettings, settingsErr := readClaimUnitSettings(filepath.Join(etcDir, "carlos", "host.env")) |
| 75 | if settingsErr != nil { |
| 76 | logf("adopt: instance-control unit settings: %v", settingsErr) |
| 77 | } |
| 78 | unitDir := filepath.Join(etcDir, "systemd", "system") |
| 79 | // systemctl answers these two in stdout and spends its EXIT STATUS on |
| 80 | // the answer's polarity: is-active exits 3 for "inactive", is-enabled |
| 81 | // exits 1 for "disabled" and again for a unit file that is not there |
| 82 | // yet. Reading a non-zero exit as a broken systemctl is what made every |
| 83 | // unarmed box — the exact box this pass exists to arm — look like a box |
| 84 | // with no systemctl at all, so the printed word is the answer and the |
| 85 | // exit status is ignored, exactly as probes.go has always done it. |
| 86 | active, activeErr := probe("is-active", systemUpdatePathUnit) |
| 87 | // The control socket's own is-active check, alongside the self-update |
| 88 | // path unit's: the same silent-systemctl-vs-unarmed-unit question, one |
| 89 | // more unit wide (design §5.2). |
| 90 | ctlActiveOut, ctlActiveErr := probe("is-active", instanceControlSocket) |
| 91 | // And the db-read worker's socket (db region forward spec §8.3), which |
| 92 | // is armed only where host.env arms db reads: elsewhere it must not |
| 93 | // listen at all, so its enablement is asked too. |
| 94 | dbrActiveOut, dbrActiveErr := probe("is-active", dbReadSocketUnit) |
| 95 | dbrEnabledOut, _ := probe("is-enabled", dbReadSocketUnit) |
| 96 | dbrWant, dbrWhy := dbReadsSocketWanted(filepath.Join(etcDir, "carlos", "host.env")) |
| 97 | // The is-enabled error is dropped on purpose: its cases are the words |
| 98 | // parseIsEnabled reads, and "no such unit file" is a perfectly good |
| 99 | // "not enabled" on the box we are here to fix. |
| 100 | enabled, _ := probe("is-enabled", systemRecoverUnit) |
| 101 | // is-active is the probe that tells a silent systemctl from an unarmed |
| 102 | // unit: it prints a state word even for a unit systemd has never heard |
| 103 | // of, so no word at all means the question could not be asked — no |
| 104 | // binary, or an exec that failed outright. |
| 105 | isActive := parseIsActive(active) |
| 106 | ctlActive := parseIsActive(ctlActiveOut) |
| 107 | dbrActive := parseIsActive(dbrActiveOut) |
| 108 | if isActive == nil || ctlActive == nil || dbrActive == nil { |
| 109 | logf("adopt: system units: systemctl said nothing; not touching the self-update units: %v / %v / %v", activeErr, ctlActiveErr, dbrActiveErr) |
| 110 | return |
| 111 | } |
| 112 | isEnabled := parseIsEnabled(enabled) |
| 113 | missing := false |
| 114 | for _, u := range systemUnitFiles { |
| 115 | if _, err := os.Stat(filepath.Join(unitDir, u)); err != nil { |
| 116 | missing = true |
| 117 | } |
| 118 | } |
| 119 | control, err := unitsFS.ReadFile("units/" + claimControlUnit) |
| 120 | if err != nil { |
| 121 | logf("adopt: system units: %v", err) |
| 122 | return |
| 123 | } |
| 124 | installed, err := os.ReadFile(filepath.Join(unitDir, claimControlUnit)) |
| 125 | if err != nil && !os.IsNotExist(err) { |
| 126 | logf("adopt: instance-control unit: %v", err) |
| 127 | return |
| 128 | } |
| 129 | controlChanged := settingsErr == nil && (claimSettings.enabled || bytes.Contains(installed, []byte(claimControlEnvironment))) && (err != nil || !bytes.Equal(installed, claimUnitContent(claimControlUnit, control, claimSettings))) |
| 130 | dbrEnabled := parseIsEnabled(dbrEnabledOut) |
| 131 | // Dark: neither listening nor enabled for the next boot. |
| 132 | dbrDark := !*dbrActive && (dbrEnabled == nil || !*dbrEnabled) |
| 133 | dbrSettled := (dbrWant && *dbrActive) || (!dbrWant && dbrDark) |
| 134 | if !missing && !controlChanged && *isActive && isEnabled != nil && *isEnabled && *ctlActive && dbrSettled { |
| 135 | return |
| 136 | } |
| 137 | if err := os.MkdirAll(unitDir, 0o755); err != nil { |
| 138 | logf("adopt: system units: %v", err) |
| 139 | return |
| 140 | } |
| 141 | for _, u := range systemUnitFiles { |
| 142 | // A host.env this pass cannot parse says nothing about the claim |
| 143 | // (6b-N2): a control unit rendered for an enabled box stays as it is. |
| 144 | if u == claimControlUnit && settingsErr != nil && bytes.Contains(installed, []byte(claimControlEnvironment)) { |
| 145 | continue |
| 146 | } |
| 147 | b, err := unitsFS.ReadFile("units/" + u) |
| 148 | if err != nil { |
| 149 | logf("adopt: system units: %v", err) |
| 150 | return |
| 151 | } |
| 152 | if err := writeFileDurable(filepath.Join(unitDir, u), claimUnitContent(u, b, claimSettings), 0o644); err != nil { |
| 153 | logf("adopt: system units: %v", err) |
| 154 | return |
| 155 | } |
| 156 | } |
| 157 | arm := ensureSystemArmed |
| 158 | if !dbrWant { |
| 159 | arm = slices.DeleteFunc(slices.Clone(arm), func(u string) bool { return u == dbReadSocketUnit }) |
| 160 | if !dbrDark { |
| 161 | logf("adopt: system units: db reads are not armed here (%s); stopping %s", dbrWhy, dbReadSocketUnit) |
| 162 | if _, err := systemctl("disable", "--now", dbReadSocketUnit); err != nil { |
| 163 | logf("adopt: system units: systemctl disable --now %s: %v", dbReadSocketUnit, err) |
| 164 | return |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | for _, args := range systemctlPlan(arm) { |
| 169 | if _, err := systemctl(args...); err != nil { |
| 170 | logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err) |
| 171 | return |
| 172 | } |
| 173 | } |
| 174 | if settingsErr == nil { |
| 175 | if err := restartClaimControl(filepath.Join(unitDir, claimControlUnit), installed, claimUnitContent(claimControlUnit, control, claimSettings), systemctl); err != nil { |
| 176 | logf("adopt: system units: %v", err) |
| 177 | return |
| 178 | } |
| 179 | } |
| 180 | logf("adopt: self-update units installed and armed") |
| 181 | } |
| 182 | |
| 183 | // parseIsEnabled maps `systemctl is-enabled`'s vocabulary onto "armed for |
| 184 | // the next boot, or not", as probes.go's parseIsActive maps is-active's onto |
| 185 | // "running now". enabled and enabled-runtime are the wants-symlink states; |
| 186 | // static, indirect, alias and generated are units that cannot be enabled at |
| 187 | // all, so an enable would be a no-op and there is nothing there to fix. |
| 188 | // Everything else — disabled, masked, linked, the empty answer a unit file |
| 189 | // that is not installed gets, a word a future systemd invents — is nil, and |
| 190 | // the caller arms a nil, because the answer this must never invent is the |
| 191 | // reassuring one. |
| 192 | func parseIsEnabled(out string) *bool { |
| 193 | yes, no := true, false |
| 194 | switch strings.TrimSpace(out) { |
| 195 | case "enabled", "enabled-runtime", "static", "indirect", "alias", "generated": |
| 196 | return &yes |
| 197 | case "disabled", "masked", "masked-runtime", "linked", "linked-runtime", "transient", "bad": |
| 198 | return &no |
| 199 | } |
| 200 | return nil |
| 201 | } |
| 202 | |
| 203 | // systemctlProbe is `systemctl <args>` asked as a QUESTION: stdout is the |
| 204 | // answer and the exit status is the caller's to ignore, so it uses Output() |
| 205 | // rather than productionSysOps().systemctl's fail-on-non-zero. The error |
| 206 | // comes back only so a caller left with no word to read can say why. |
| 207 | func systemctlProbe(args ...string) (string, error) { |
| 208 | out, err := exec.Command("systemctl", args...).Output() |
| 209 | return string(out), err |
| 210 | } |
| 211 | |