| new file mode 100644 |
| index 000000000..5928e9504 |
| --- /dev/null |
| +++ b/internal/host/systemstate.go |
| @@ -0,0 +1,207 @@ |
| +package host |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "time" |
| +) |
| + |
| +// The on-box state of the platform self-update (spec §4, §6). Two homes: |
| +// |
| +// <data>/system/router/ — written by the sandboxed agent: the |
| +// request and the artifact cache. Root |
| +// reads exactly one file here and trusts |
| +// nothing in it beyond a version string. |
| +// <opt>/system/router/ — written by root: journal, seen (replay |
| +// watermarks), applied (the .prev |
| +// authority), result (what the agent |
| +// publishes). Root-owned, world-readable. |
| +const ( |
| + systemComponent = "router" |
| + systemRequestMarker = "system-update-requested" |
| + systemRequestEvery = time.Minute |
| + systemArtifactLimit = int64(96 << 20) |
| + |
| + systemOutcomeApplied = "applied" |
| + systemOutcomeRolledBack = "rolled-back" |
| + systemOutcomeRollbackFailed = "rollback-failed" |
| + systemOutcomeRefused = "refused" |
| + systemOutcomeDeferred = "deferred" |
| + systemOutcomeNoted = "noted" |
| + |
| + systemPhaseBackingUp = "backing-up" |
| + systemPhaseInstalling = "installing" |
| + systemPhaseInstalled = "installed" |
| + systemPhaseUnits = "units" |
| + systemPhaseRestarted = "restarted" |
| + systemPhaseGated = "gated" |
| +) |
| + |
| +// systemRequest is the agent's hint to root: which promote it saw. No |
| +// channel (root's own host.env says), no key, no sha, no path (root derives |
| +// the cache path from the version and takes key and sha from the pointer). |
| +type systemRequest struct { |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + NoOp bool `json:"noop"` |
| + SeenAt time.Time `json:"seen_at"` |
| +} |
| + |
| +// systemResult is root's answer to the last request, keyed by the REQUEST's |
| +// tuple so a refusal of one promote never suppresses the next. |
| +type systemResult struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + Outcome string `json:"outcome"` |
| + Reason string `json:"reason,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +func (r *systemResult) matches(channel, version string, promotedAt time.Time) bool { |
| + return r != nil && r.Channel == channel && r.Version == version && r.PromotedAt.Equal(promotedAt) |
| +} |
| + |
| +// terminal: outcomes the leg must not retry for the same pointer. |
| +func (r *systemResult) terminal() bool { |
| + switch r.Outcome { |
| + case systemOutcomeApplied, systemOutcomeRolledBack, systemOutcomeRollbackFailed, systemOutcomeRefused: |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// systemApplied is the last build that PASSED A GATE on this box, forward or |
| +// rollback. It is the only thing that may authorise refreshing carlos.prev. |
| +type systemApplied struct { |
| + Version string `json:"version"` |
| + Channel string `json:"channel"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// systemSeen is the replay watermark per channel: the newest PromotedAt root |
| +// has verified. Advanced on every verified pointer, including no-ops. |
| +type systemSeen map[string]time.Time |
| + |
| +// systemJournal is the open transaction. Written before every box-changing |
| +// step, removed last; a run that finds one recovers before anything else. |
| +type systemJournal struct { |
| + Phase string `json:"phase"` |
| + Target string `json:"target"` |
| + PrevBuild string `json:"prev_build"` |
| + UnitsAdded []string `json:"units_added,omitempty"` |
| + UnitsRemoved []string `json:"units_removed,omitempty"` |
| + StartedAt time.Time `json:"started_at"` |
| +} |
| + |
| +var systemVersionRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) |
| + |
| +// validSystemVersion is what makes the cache path derivable: no leading dot, |
| +// no separator, nothing that could be a traversal. |
| +func validSystemVersion(v string) bool { return systemVersionRe.MatchString(v) } |
| + |
| +func systemDataDir(dd string) string { return filepath.Join(dd, "system", systemComponent) } |
| +func systemRequestPath(dd string) string { |
| + return filepath.Join(systemDataDir(dd), "requested.json") |
| +} |
| +func systemCachePath(dd, version string) string { |
| + return filepath.Join(systemDataDir(dd), version, "carlos") |
| +} |
| +func systemRootDir(optDir string) string { return filepath.Join(optDir, "system", systemComponent) } |
| +func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") } |
| +func systemBinaryPath(optDir string) string { return filepath.Join(optDir, "carlos") } |
| + |
| +// writeFileDurable: temp, fsync, rename, directory fsync. A crash leaves the |
| +// old file or the new one, never a torn one, and the rename itself is on |
| +// disk before the caller's next step depends on it. |
| +func writeFileDurable(path string, data []byte, mode os.FileMode) (err error) { |
| + tmp := path + ".tmp" |
| + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { |
| + if err != nil { |
| + f.Close() |
| + os.Remove(tmp) |
| + } |
| + }() |
| + if _, err = f.Write(data); err != nil { |
| + return err |
| + } |
| + if err = f.Chmod(mode); err != nil { |
| + return err |
| + } |
| + if err = f.Sync(); err != nil { |
| + return err |
| + } |
| + if err = f.Close(); err != nil { |
| + return err |
| + } |
| + if err = os.Rename(tmp, path); err != nil { |
| + return err |
| + } |
| + d, err := os.Open(filepath.Dir(path)) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + return d.Sync() |
| +} |
| + |
| +func writeJSONDurable(path string, v any, mode os.FileMode) error { |
| + b, err := json.MarshalIndent(v, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return writeFileDurable(path, append(b, '\n'), mode) |
| +} |
| + |
| +// readJSONFile leaves os.ErrNotExist intact so callers can treat "no file" |
| +// as a state, not a failure. |
| +func readJSONFile(path string, v any) error { |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + return json.Unmarshal(b, v) |
| +} |
| + |
| +func readSystemResult(optDir string) (*systemResult, error) { |
| + var r systemResult |
| + err := readJSONFile(filepath.Join(systemRootDir(optDir), "result.json"), &r) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + return &r, err |
| +} |
| + |
| +func readSystemSeen(optDir string) (systemSeen, error) { |
| + s := systemSeen{} |
| + err := readJSONFile(filepath.Join(systemRootDir(optDir), "seen.json"), &s) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return s, nil |
| + } |
| + return s, err |
| +} |
| + |
| +func readSystemApplied(optDir string) (*systemApplied, error) { |
| + var a systemApplied |
| + err := readJSONFile(filepath.Join(systemRootDir(optDir), "applied.json"), &a) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + return &a, err |
| +} |
| + |
| +func readSystemJournal(optDir string) (*systemJournal, error) { |
| + var j systemJournal |
| + err := readJSONFile(filepath.Join(systemRootDir(optDir), "journal.json"), &j) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + return &j, err |
| +} |