| index 1513756a9..6440bc2f0 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -174,7 +174,7 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| 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()} |
| + UnitsAdded: unitsMinus(newUnits, prevUnits), UnitsRemoved: unitsMinus(prevUnits, newUnits), StartedAt: a.ops.now().UTC()} |
| if err := a.journal(j); err != nil { |
| return err |
| } |
| @@ -197,7 +197,7 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| if err := a.journal(j); err != nil { |
| return fail("journal", err) |
| } |
| - if err := a.installUnits(newDir, j.UnitsRemoved); err != nil { |
| + if err := a.installUnits(ctx, newDir, j.UnitsRemoved); err != nil { |
| return fail("units", err) |
| } |
| j.Phase = systemPhaseUnits |
| @@ -223,17 +223,34 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| |
| // finish is steps 11–12, and what recovery runs forward from phase gated. |
| func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapPlan) error { |
| + reason := "" |
| if err := a.arm(plan); err != nil { |
| + // The gate passed, so this run is an apply and rolling back now |
| + // would undo a build that proved itself. But `enable` is one |
| + // batched call: one bad unit leaves the box on the new binary with |
| + // nothing armed, and a fleet-visible reason is the only way anyone |
| + // finds out. |
| a.ops.logf("system apply: arm: %v", err) |
| + reason = "armed with errors: " + err.Error() |
| } |
| 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 { |
| + if err := a.writeResult(req, systemOutcomeApplied, reason); err != nil { |
| return err |
| } |
| + a.removeJournal() |
| + return nil |
| +} |
| + |
| +// removeJournal closes the transaction, durably: an unsynced removal can |
| +// come back after a power loss, and a journal that outlives the run it |
| +// belongs to is one the next run recovers from. |
| +func (a *systemApplier) removeJournal() { |
| os.Remove(a.file("journal.json")) |
| - return syncDirBestEffort(a.rootDir()) |
| + if err := syncDir(a.rootDir()); err != nil { |
| + a.ops.logf("system apply: fsync %s: %v", a.rootDir(), err) |
| + } |
| } |
| |
| // decidePrev refreshes carlos.prev from the on-disk binary only when that |
| @@ -259,7 +276,7 @@ func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) { |
| if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil { |
| return false, err |
| } |
| - os.Remove(a.file("journal.json")) |
| + a.removeJournal() |
| return true, nil |
| } |
| |
| @@ -308,8 +325,11 @@ func (a *systemApplier) journal(j systemJournal) error { |
| // 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 { |
| +func (a *systemApplier) installUnits(ctx context.Context, stagedRoot string, remove []string) error { |
| unitDir := filepath.Join(a.etcDir, "systemd", "system") |
| + if err := os.MkdirAll(unitDir, 0o755); err != nil { |
| + return err |
| + } |
| for _, u := range remove { |
| if _, err := a.ops.systemctl("disable", "--now", u); err != nil { |
| a.ops.logf("system apply: disable %s: %v", u, err) |
| @@ -337,7 +357,7 @@ func (a *systemApplier) installUnits(stagedRoot string, remove []string) error { |
| if err := copyFileDurable(sys, dst, 0o644); err != nil { |
| return err |
| } |
| - if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-sysusers", dst); err != nil { |
| + if _, err := a.ops.run(ctx, systemVersionWait, "systemd-sysusers", dst); err != nil { |
| return fmt.Errorf("systemd-sysusers: %w", err) |
| } |
| } |
| @@ -349,7 +369,7 @@ func (a *systemApplier) installUnits(stagedRoot string, remove []string) error { |
| if err := copyFileDurable(tmp, dst, 0o644); err != nil { |
| return err |
| } |
| - if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-tmpfiles", "--create", dst); err != nil { |
| + if _, err := a.ops.run(ctx, 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") |
| @@ -365,9 +385,11 @@ func (a *systemApplier) restartEdge() error { |
| } |
| |
| // 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. |
| +// installed binary reports the target; then a soak across which NRestarts |
| +// stays 0 — step 9's reset-failed zeroed it, so anything above 0 is a |
| +// crash the new build caused — and ActiveEnterTimestampMonotonic does not |
| +// move. A build that starts, answers, and dies a second later is caught |
| +// here, and so is one that crashed once and then settled. |
| func (a *systemApplier) gate(ctx context.Context, target string) error { |
| wait, soak := a.gateWait, a.soak |
| if wait <= 0 { |
| @@ -388,24 +410,56 @@ func (a *systemApplier) gate(ctx context.Context, target string) error { |
| } |
| a.ops.sleep(time.Second) |
| } |
| - before, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit) |
| + before, err := a.edgeRunState() |
| if err != nil { |
| return err |
| } |
| + if err := before.steady(); err != nil { |
| + return err |
| + } |
| a.ops.sleep(soak) |
| - after, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit) |
| + after, err := a.edgeRunState() |
| 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", "/")) |
| + if err := after.steady(); err != nil { |
| + return err |
| + } |
| + if before != after { |
| + return fmt.Errorf("edge restarted during the soak (%s → %s)", before, after) |
| } |
| return a.gateOnce(ctx, target) |
| } |
| |
| +// edgeRunState is the edge's NRestarts and ActiveEnterTimestampMonotonic, |
| +// in that order — the order `show -p` is asked for them. |
| +type edgeRunState struct{ restarts, activeEnter string } |
| + |
| +func (s edgeRunState) String() string { return s.restarts + "/" + s.activeEnter } |
| + |
| +// steady is the half of the soak that a comparison cannot make: a restart |
| +// that happened before the first sample is still a restart. |
| +func (s edgeRunState) steady() error { |
| + if s.restarts != "0" { |
| + return fmt.Errorf("edge has crash-restarted since the install (NRestarts=%s)", s.restarts) |
| + } |
| + return nil |
| +} |
| + |
| +func (a *systemApplier) edgeRunState() (edgeRunState, error) { |
| + out, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit) |
| + if err != nil { |
| + return edgeRunState{}, err |
| + } |
| + restarts, rest, _ := strings.Cut(strings.TrimSpace(out), "\n") |
| + return edgeRunState{restarts: strings.TrimSpace(restarts), activeEnter: strings.TrimSpace(rest)}, nil |
| +} |
| + |
| 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" { |
| + if err != nil { |
| + return fmt.Errorf("%s is %s: %w", systemEdgeUnit, strings.TrimSpace(out), err) |
| + } |
| return fmt.Errorf("%s is %s", systemEdgeUnit, strings.TrimSpace(out)) |
| } |
| if err := a.ops.probeEdge(); err != nil { |
| @@ -435,8 +489,8 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| return errors.New("rollback not implemented: " + reason) |
| } |
| |
| -// diff is have − drop, order preserved. |
| -func diff(have, drop []string) []string { |
| +// unitsMinus is have − drop, order preserved. |
| +func unitsMinus(have, drop []string) []string { |
| skip := map[string]bool{} |
| for _, d := range drop { |
| skip[d] = true |
| @@ -451,49 +505,17 @@ func diff(have, drop []string) []string { |
| } |
| |
| // 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) { |
| +// memory — the binary itself — and lands the same way. |
| +func copyFileDurable(src, dst string, mode os.FileMode) 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 writeDurable(dst, mode, func(w io.Writer) error { |
| + _, err := io.Copy(w, in) |
| 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 { |