package host import ( "encoding/json" "errors" "io" "os" "path/filepath" "regexp" "time" "github.com/carlosframework/platform/internal/safefile" ) // The on-box state of the platform self-update (spec §4, §6). Two homes: // // /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. // /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 { if r == nil { return false } 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. // Target and PromotedAt are the promote it belongs to: the same tuple the // result is keyed by, so a recovery answers for the apply that was cut off // and not for whatever the agent has asked for since. type systemJournal struct { Phase string `json:"phase"` Target string `json:"target"` PromotedAt time.Time `json:"promoted_at"` PrevBuild string `json:"prev_build"` UnitsAdded []string `json:"units_added,omitempty"` UnitsRemoved []string `json:"units_removed,omitempty"` StartedAt time.Time `json:"started_at"` } // promoted is the promote the journal is an apply of. A journal written by // a build from before the field existed has only StartedAt to offer, which // is what recovery keyed results by then. func (j *systemJournal) promoted() time.Time { if j.PromotedAt.IsZero() { return j.StartedAt } return j.PromotedAt } 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") } // systemCacheParts are the components of the agent's cache entry below the // data dir. Root walks them one at a time (openBeneath) rather than opening // the joined path, so the two must come from here and never drift apart. func systemCacheParts(version string) []string { return []string{"system", systemComponent, version, "carlos"} } func systemCachePath(dd, version string) string { return filepath.Join(append([]string{dd}, systemCacheParts(version)...)...) } 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") } // writeDurable publishes a file only after its contents are synced, then // syncs the parent directory. The shared writer pins directory descriptors // and keeps the temporary file in a private directory, including under /data. func writeDurable(path string, mode os.FileMode, fn func(io.Writer) error) (err error) { return safefile.Write(path, mode, fn) } func writeFileDurable(path string, data []byte, mode os.FileMode) error { return writeDurable(path, mode, func(w io.Writer) error { _, err := w.Write(data) return err }) } // syncDir fsyncs a directory, so a rename into it is on disk. What a // failure means is the caller's to decide: after a recorded apply it is a // line in the log, before one it is a reason to stop. func syncDir(dir string) error { d, err := os.Open(dir) 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 := safefile.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 systemSeen{}, nil } if s == nil { s = systemSeen{} // a literal `null` on disk unmarshals to a nil map } 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 }