| index df6995191..1513756a9 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -42,9 +42,8 @@ var systemQuiesceUnits = []string{ |
| } |
| |
| var ( |
| - errSystemRefused = errors.New("refused") |
| - errSystemDeferred = errors.New("deferred") |
| - errNotYetImplemented = errors.New("system apply: install path not implemented") |
| + errSystemRefused = errors.New("refused") |
| + errSystemDeferred = errors.New("deferred") |
| |
| // errSystemUnsupported is what the non-linux build's primitives return. |
| // It is declared here so Run can tell it from a lock another apply holds |
| @@ -150,11 +149,351 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| if err != nil { |
| return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| } |
| - return a.install(ctx, req, p, staged) // Task 8b |
| + return a.install(ctx, req, p, staged) |
| } |
| |
| +// install is steps 6–12: decide .prev, stage both unit sets, then the |
| +// box-changing dance — each step journaled before it happens, and any |
| +// failure from the quiesce on going to the rollback of step 13. |
| func (a *systemApplier) install(ctx context.Context, req systemRequest, p *release.SystemPointer, staged string) error { |
| - return errNotYetImplemented |
| + target := p.Current.Version |
| + prevOK, err := a.decidePrev(ctx) |
| + if err != nil { |
| + return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| + } |
| + if !prevOK { |
| + return a.record(req, fmt.Errorf("%w: no known-good carlos.prev to fall back to", errSystemRefused)) |
| + } |
| + newDir, prevDir := filepath.Join(a.stagingDir(), "new"), filepath.Join(a.stagingDir(), "prev") |
| + newPlan, newUnits, err := a.stagePlan(ctx, staged, newDir) |
| + if err != nil { |
| + return a.record(req, fmt.Errorf("%w: candidate bootstrap: %v", errSystemRefused, err)) |
| + } |
| + prevPlan, prevUnits, err := a.stagePlan(ctx, systemPrevPath(a.optDir), prevDir) |
| + if err != nil { |
| + return a.record(req, fmt.Errorf("%w: carlos.prev bootstrap: %v", errSystemRefused, err)) |
| + } |
| + j := systemJournal{Phase: systemPhaseInstalling, Target: target, PrevBuild: a.prevBuild(ctx), |
| + UnitsAdded: diff(newUnits, prevUnits), UnitsRemoved: diff(prevUnits, newUnits), StartedAt: a.ops.now().UTC()} |
| + if err := a.journal(j); err != nil { |
| + return err |
| + } |
| + fail := func(step string, err error) error { |
| + return a.rollback(ctx, req, fmt.Sprintf("%s: %v", step, err), &j, prevPlan) |
| + } |
| + // Stop the root passes that exec the old binary against the old edge |
| + // before the rename; one that is already running is killed, and those |
| + // passes are idempotent. |
| + if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil { |
| + a.ops.logf("system apply: quiesce: %v (continuing)", err) |
| + } |
| + if err := os.Rename(staged, systemBinaryPath(a.optDir)); err != nil { |
| + return fail("install", err) |
| + } |
| + if err := os.Chown(systemBinaryPath(a.optDir), 0, 0); err != nil && !errors.Is(err, os.ErrPermission) { |
| + a.ops.logf("system apply: chown root: %v", err) |
| + } |
| + j.Phase = systemPhaseInstalled |
| + if err := a.journal(j); err != nil { |
| + return fail("journal", err) |
| + } |
| + if err := a.installUnits(newDir, j.UnitsRemoved); err != nil { |
| + return fail("units", err) |
| + } |
| + j.Phase = systemPhaseUnits |
| + if err := a.journal(j); err != nil { |
| + return fail("journal", err) |
| + } |
| + if err := a.restartEdge(); err != nil { |
| + return fail("restart", err) |
| + } |
| + j.Phase = systemPhaseRestarted |
| + if err := a.journal(j); err != nil { |
| + return fail("journal", err) |
| + } |
| + if err := a.gate(ctx, target); err != nil { |
| + return fail("gate", err) |
| + } |
| + j.Phase = systemPhaseGated |
| + if err := a.journal(j); err != nil { |
| + return fail("journal", err) |
| + } |
| + return a.finish(req, target, newPlan) |
| +} |
| + |
| +// finish is steps 11–12, and what recovery runs forward from phase gated. |
| +func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapPlan) error { |
| + if err := a.arm(plan); err != nil { |
| + a.ops.logf("system apply: arm: %v", err) |
| + } |
| + if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: target, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil { |
| + return err |
| + } |
| + if err := a.writeResult(req, systemOutcomeApplied, ""); err != nil { |
| + return err |
| + } |
| + os.Remove(a.file("journal.json")) |
| + return syncDirBestEffort(a.rootDir()) |
| +} |
| + |
| +// decidePrev refreshes carlos.prev from the on-disk binary only when that |
| +// binary is known good — applied.json names it, or nothing has ever been |
| +// applied and there is no .prev (a hand-rolled box whose running binary |
| +// passed the script's own post-condition). Otherwise it leaves .prev alone |
| +// and reports whether there is one to fall back to at all. |
| +func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) { |
| + prev := systemPrevPath(a.optDir) |
| + _, prevErr := os.Stat(prev) |
| + applied, err := readSystemApplied(a.optDir) |
| + if err != nil { |
| + return false, err |
| + } |
| + disk := a.onDiskBuild(ctx) |
| + known := (applied != nil && applied.Version == disk && disk != "") || (applied == nil && prevErr != nil) |
| + if !known { |
| + return prevErr == nil, nil |
| + } |
| + if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "", PrevBuild: disk, StartedAt: a.ops.now().UTC()}); err != nil { |
| + return false, err |
| + } |
| + if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil { |
| + return false, err |
| + } |
| + os.Remove(a.file("journal.json")) |
| + return true, nil |
| +} |
| + |
| +func (a *systemApplier) prevBuild(ctx context.Context) string { |
| + out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version") |
| + if err != nil { |
| + return "" |
| + } |
| + return carlosVersionOf(out) |
| +} |
| + |
| +// stagePlan runs `<binary> bootstrap -root <dir> -plan-json` and returns |
| +// that binary's arming plan and the unit files it staged. |
| +func (a *systemApplier) stagePlan(ctx context.Context, binary, dir string) (bootstrapPlan, []string, error) { |
| + os.RemoveAll(dir) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + return bootstrapPlan{}, nil, err |
| + } |
| + out, err := a.ops.run(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json") |
| + if err != nil { |
| + return bootstrapPlan{}, nil, err |
| + } |
| + var plan bootstrapPlan |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil { |
| + return bootstrapPlan{}, nil, fmt.Errorf("plan: %w", err) |
| + } |
| + entries, err := os.ReadDir(filepath.Join(dir, "etc", "systemd", "system")) |
| + if err != nil { |
| + return bootstrapPlan{}, nil, err |
| + } |
| + var units []string |
| + for _, e := range entries { |
| + if !e.IsDir() { |
| + units = append(units, e.Name()) |
| + } |
| + } |
| + return plan, units, nil |
| +} |
| + |
| +func (a *systemApplier) journal(j systemJournal) error { |
| + return writeJSONDurable(a.file("journal.json"), j, 0o600) |
| +} |
| + |
| +// installUnits removes what the target build dropped, copies its units in, |
| +// then sysusers BEFORE tmpfiles — tmpfiles silently drops a line whose |
| +// group does not resolve, and carlos-edge never starts again after the |
| +// next boot — then daemon-reload. Never the staging root's data/: it holds |
| +// a 0-byte registry.db. |
| +func (a *systemApplier) installUnits(stagedRoot string, remove []string) error { |
| + unitDir := filepath.Join(a.etcDir, "systemd", "system") |
| + for _, u := range remove { |
| + if _, err := a.ops.systemctl("disable", "--now", u); err != nil { |
| + a.ops.logf("system apply: disable %s: %v", u, err) |
| + } |
| + os.Remove(filepath.Join(unitDir, u)) |
| + } |
| + entries, err := os.ReadDir(filepath.Join(stagedRoot, "etc", "systemd", "system")) |
| + if err != nil { |
| + return err |
| + } |
| + for _, e := range entries { |
| + if e.IsDir() { |
| + continue |
| + } |
| + if err := copyFileDurable(filepath.Join(stagedRoot, "etc", "systemd", "system", e.Name()), filepath.Join(unitDir, e.Name()), 0o644); err != nil { |
| + return err |
| + } |
| + } |
| + sys := filepath.Join(stagedRoot, "etc", "sysusers.d", "carlos.conf") |
| + if _, err := os.Stat(sys); err == nil { |
| + if err := os.MkdirAll(filepath.Join(a.etcDir, "sysusers.d"), 0o755); err != nil { |
| + return err |
| + } |
| + dst := filepath.Join(a.etcDir, "sysusers.d", "carlos.conf") |
| + if err := copyFileDurable(sys, dst, 0o644); err != nil { |
| + return err |
| + } |
| + if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-sysusers", dst); err != nil { |
| + return fmt.Errorf("systemd-sysusers: %w", err) |
| + } |
| + } |
| + tmp := filepath.Join(stagedRoot, "etc", "tmpfiles.d", "carlos.conf") |
| + if err := os.MkdirAll(filepath.Join(a.etcDir, "tmpfiles.d"), 0o755); err != nil { |
| + return err |
| + } |
| + dst := filepath.Join(a.etcDir, "tmpfiles.d", "carlos.conf") |
| + if err := copyFileDurable(tmp, dst, 0o644); err != nil { |
| + return err |
| + } |
| + if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-tmpfiles", "--create", dst); err != nil { |
| + a.ops.logf("system apply: systemd-tmpfiles: %v (continuing, as the script does)", err) |
| + } |
| + _, err = a.ops.systemctl("daemon-reload") |
| + return err |
| +} |
| + |
| +// restartEdge resets the failure counter FIRST — a unit that hit its start |
| +// limit refuses restart, and so would the rollback — then restarts. |
| +func (a *systemApplier) restartEdge() error { |
| + a.ops.systemctl("reset-failed", systemEdgeUnit) |
| + _, err := a.ops.systemctl("restart", systemEdgeUnit) |
| + return err |
| +} |
| + |
| +// gate: within gateWait the edge is active, answers on loopback, and the |
| +// installed binary reports the target; then a soak across which neither |
| +// NRestarts nor ActiveEnterTimestampMonotonic moves, so a build that |
| +// starts, answers and dies a second later is caught here. |
| +func (a *systemApplier) gate(ctx context.Context, target string) error { |
| + wait, soak := a.gateWait, a.soak |
| + if wait <= 0 { |
| + wait = systemGateWait |
| + } |
| + if soak <= 0 { |
| + soak = systemGateSoak |
| + } |
| + deadline := a.ops.now().Add(wait) |
| + var lastErr error |
| + for { |
| + lastErr = a.gateOnce(ctx, target) |
| + if lastErr == nil { |
| + break |
| + } |
| + if !a.ops.now().Before(deadline) { |
| + return lastErr |
| + } |
| + a.ops.sleep(time.Second) |
| + } |
| + before, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit) |
| + if err != nil { |
| + return err |
| + } |
| + a.ops.sleep(soak) |
| + after, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit) |
| + if err != nil { |
| + return err |
| + } |
| + if strings.TrimSpace(before) != strings.TrimSpace(after) { |
| + return fmt.Errorf("edge restarted during the soak (%s → %s)", |
| + strings.ReplaceAll(strings.TrimSpace(before), "\n", "/"), strings.ReplaceAll(strings.TrimSpace(after), "\n", "/")) |
| + } |
| + return a.gateOnce(ctx, target) |
| +} |
| + |
| +func (a *systemApplier) gateOnce(ctx context.Context, target string) error { |
| + if out, err := a.ops.systemctl("is-active", systemEdgeUnit); err != nil || strings.TrimSpace(out) != "active" { |
| + return fmt.Errorf("%s is %s", systemEdgeUnit, strings.TrimSpace(out)) |
| + } |
| + if err := a.ops.probeEdge(); err != nil { |
| + return fmt.Errorf("edge probe: %w", err) |
| + } |
| + if got := a.onDiskBuild(ctx); got != target { |
| + return fmt.Errorf("installed binary reports %q, not %s", got, target) |
| + } |
| + return nil |
| +} |
| + |
| +// arm is systemctlPlan verbatim: enable all, start timers and paths, never |
| +// a service — which is why apply can call it from inside itself. |
| +func (a *systemApplier) arm(plan bootstrapPlan) error { |
| + for _, args := range systemctlPlan(plan.Enabled) { |
| + if _, err := a.ops.systemctl(args...); err != nil { |
| + return fmt.Errorf("systemctl %s: %w", strings.Join(args, " "), err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// rollback is step 13 (Task 8c). Until then it records the outcome the |
| +// box is actually in and fails loudly rather than pretending. |
| +func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason string, j *systemJournal, prevPlan bootstrapPlan) error { |
| + _ = a.writeResult(req, systemOutcomeRolledBack, reason) |
| + return errors.New("rollback not implemented: " + reason) |
| +} |
| + |
| +// diff is have − drop, order preserved. |
| +func diff(have, drop []string) []string { |
| + skip := map[string]bool{} |
| + for _, d := range drop { |
| + skip[d] = true |
| + } |
| + var out []string |
| + for _, h := range have { |
| + if !skip[h] { |
| + out = append(out, h) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// copyFileDurable is writeFileDurable for a file too large to hold in |
| +// memory: temp, fsync, rename, directory fsync. |
| +func copyFileDurable(src, dst string, mode os.FileMode) (err error) { |
| + in, err := os.Open(src) |
| + if err != nil { |
| + return err |
| + } |
| + defer in.Close() |
| + tmp := dst + ".tmp" |
| + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { |
| + if err != nil { |
| + out.Close() |
| + os.Remove(tmp) |
| + } |
| + }() |
| + if _, err = io.Copy(out, in); err != nil { |
| + return err |
| + } |
| + if err = out.Chmod(mode); err != nil { |
| + return err |
| + } |
| + if err = out.Sync(); err != nil { |
| + return err |
| + } |
| + if err = out.Close(); err != nil { |
| + return err |
| + } |
| + if err = os.Rename(tmp, dst); err != nil { |
| + return err |
| + } |
| + return syncDirBestEffort(filepath.Dir(dst)) |
| +} |
| + |
| +func syncDirBestEffort(dir string) error { |
| + d, err := os.Open(dir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + return d.Sync() |
| } |
| |
| func (a *systemApplier) recoverIfNeeded(ctx context.Context) error { |