| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "io" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "regexp" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/safefile" |
| 13 | ) |
| 14 | |
| 15 | // The on-box state of the platform self-update (spec §4, §6). Two homes: |
| 16 | // |
| 17 | // <data>/system/router/ — written by the sandboxed agent: the |
| 18 | // request and the artifact cache. Root |
| 19 | // reads exactly one file here and trusts |
| 20 | // nothing in it beyond a version string. |
| 21 | // <opt>/system/router/ — written by root: journal, seen (replay |
| 22 | // watermarks), applied (the .prev |
| 23 | // authority), result (what the agent |
| 24 | // publishes). Root-owned, world-readable. |
| 25 | const ( |
| 26 | systemComponent = "router" |
| 27 | systemRequestMarker = "system-update-requested" |
| 28 | systemRequestEvery = time.Minute |
| 29 | systemArtifactLimit = int64(96 << 20) |
| 30 | |
| 31 | systemOutcomeApplied = "applied" |
| 32 | systemOutcomeRolledBack = "rolled-back" |
| 33 | systemOutcomeRollbackFailed = "rollback-failed" |
| 34 | systemOutcomeRefused = "refused" |
| 35 | systemOutcomeDeferred = "deferred" |
| 36 | systemOutcomeNoted = "noted" |
| 37 | |
| 38 | systemPhaseBackingUp = "backing-up" |
| 39 | systemPhaseInstalling = "installing" |
| 40 | systemPhaseInstalled = "installed" |
| 41 | systemPhaseUnits = "units" |
| 42 | systemPhaseRestarted = "restarted" |
| 43 | systemPhaseGated = "gated" |
| 44 | ) |
| 45 | |
| 46 | // systemRequest is the agent's hint to root: which promote it saw. No |
| 47 | // channel (root's own host.env says), no key, no sha, no path (root derives |
| 48 | // the cache path from the version and takes key and sha from the pointer). |
| 49 | type systemRequest struct { |
| 50 | Version string `json:"version"` |
| 51 | PromotedAt time.Time `json:"promoted_at"` |
| 52 | NoOp bool `json:"noop"` |
| 53 | SeenAt time.Time `json:"seen_at"` |
| 54 | } |
| 55 | |
| 56 | // systemResult is root's answer to the last request, keyed by the REQUEST's |
| 57 | // tuple so a refusal of one promote never suppresses the next. |
| 58 | type systemResult struct { |
| 59 | Channel string `json:"channel"` |
| 60 | Version string `json:"version"` |
| 61 | PromotedAt time.Time `json:"promoted_at"` |
| 62 | Outcome string `json:"outcome"` |
| 63 | Reason string `json:"reason,omitempty"` |
| 64 | At time.Time `json:"at"` |
| 65 | } |
| 66 | |
| 67 | func (r *systemResult) matches(channel, version string, promotedAt time.Time) bool { |
| 68 | return r != nil && r.Channel == channel && r.Version == version && r.PromotedAt.Equal(promotedAt) |
| 69 | } |
| 70 | |
| 71 | // terminal: outcomes the leg must not retry for the same pointer. |
| 72 | func (r *systemResult) terminal() bool { |
| 73 | if r == nil { |
| 74 | return false |
| 75 | } |
| 76 | switch r.Outcome { |
| 77 | case systemOutcomeApplied, systemOutcomeRolledBack, systemOutcomeRollbackFailed, systemOutcomeRefused: |
| 78 | return true |
| 79 | } |
| 80 | return false |
| 81 | } |
| 82 | |
| 83 | // systemApplied is the last build that PASSED A GATE on this box, forward or |
| 84 | // rollback. It is the only thing that may authorise refreshing carlos.prev. |
| 85 | type systemApplied struct { |
| 86 | Version string `json:"version"` |
| 87 | Channel string `json:"channel"` |
| 88 | At time.Time `json:"at"` |
| 89 | } |
| 90 | |
| 91 | // systemSeen is the replay watermark per channel: the newest PromotedAt root |
| 92 | // has verified. Advanced on every verified pointer, including no-ops. |
| 93 | type systemSeen map[string]time.Time |
| 94 | |
| 95 | // systemJournal is the open transaction. Written before every box-changing |
| 96 | // step, removed last; a run that finds one recovers before anything else. |
| 97 | // Target and PromotedAt are the promote it belongs to: the same tuple the |
| 98 | // result is keyed by, so a recovery answers for the apply that was cut off |
| 99 | // and not for whatever the agent has asked for since. |
| 100 | type systemJournal struct { |
| 101 | Phase string `json:"phase"` |
| 102 | Target string `json:"target"` |
| 103 | PromotedAt time.Time `json:"promoted_at"` |
| 104 | PrevBuild string `json:"prev_build"` |
| 105 | UnitsAdded []string `json:"units_added,omitempty"` |
| 106 | UnitsRemoved []string `json:"units_removed,omitempty"` |
| 107 | StartedAt time.Time `json:"started_at"` |
| 108 | } |
| 109 | |
| 110 | // promoted is the promote the journal is an apply of. A journal written by |
| 111 | // a build from before the field existed has only StartedAt to offer, which |
| 112 | // is what recovery keyed results by then. |
| 113 | func (j *systemJournal) promoted() time.Time { |
| 114 | if j.PromotedAt.IsZero() { |
| 115 | return j.StartedAt |
| 116 | } |
| 117 | return j.PromotedAt |
| 118 | } |
| 119 | |
| 120 | var systemVersionRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) |
| 121 | |
| 122 | // validSystemVersion is what makes the cache path derivable: no leading dot, |
| 123 | // no separator, nothing that could be a traversal. |
| 124 | func validSystemVersion(v string) bool { return systemVersionRe.MatchString(v) } |
| 125 | |
| 126 | func systemDataDir(dd string) string { return filepath.Join(dd, "system", systemComponent) } |
| 127 | func systemRequestPath(dd string) string { |
| 128 | return filepath.Join(systemDataDir(dd), "requested.json") |
| 129 | } |
| 130 | |
| 131 | // systemCacheParts are the components of the agent's cache entry below the |
| 132 | // data dir. Root walks them one at a time (openBeneath) rather than opening |
| 133 | // the joined path, so the two must come from here and never drift apart. |
| 134 | func systemCacheParts(version string) []string { |
| 135 | return []string{"system", systemComponent, version, "carlos"} |
| 136 | } |
| 137 | |
| 138 | func systemCachePath(dd, version string) string { |
| 139 | return filepath.Join(append([]string{dd}, systemCacheParts(version)...)...) |
| 140 | } |
| 141 | func systemRootDir(optDir string) string { return filepath.Join(optDir, "system", systemComponent) } |
| 142 | func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") } |
| 143 | func systemBinaryPath(optDir string) string { return filepath.Join(optDir, "carlos") } |
| 144 | |
| 145 | // writeDurable publishes a file only after its contents are synced, then |
| 146 | // syncs the parent directory. The shared writer pins directory descriptors |
| 147 | // and keeps the temporary file in a private directory, including under /data. |
| 148 | func writeDurable(path string, mode os.FileMode, fn func(io.Writer) error) (err error) { |
| 149 | return safefile.Write(path, mode, fn) |
| 150 | } |
| 151 | |
| 152 | func writeFileDurable(path string, data []byte, mode os.FileMode) error { |
| 153 | return writeDurable(path, mode, func(w io.Writer) error { |
| 154 | _, err := w.Write(data) |
| 155 | return err |
| 156 | }) |
| 157 | } |
| 158 | |
| 159 | // syncDir fsyncs a directory, so a rename into it is on disk. What a |
| 160 | // failure means is the caller's to decide: after a recorded apply it is a |
| 161 | // line in the log, before one it is a reason to stop. |
| 162 | func syncDir(dir string) error { |
| 163 | d, err := os.Open(dir) |
| 164 | if err != nil { |
| 165 | return err |
| 166 | } |
| 167 | defer d.Close() |
| 168 | return d.Sync() |
| 169 | } |
| 170 | |
| 171 | func writeJSONDurable(path string, v any, mode os.FileMode) error { |
| 172 | b, err := json.MarshalIndent(v, "", " ") |
| 173 | if err != nil { |
| 174 | return err |
| 175 | } |
| 176 | return writeFileDurable(path, append(b, '\n'), mode) |
| 177 | } |
| 178 | |
| 179 | // readJSONFile leaves os.ErrNotExist intact so callers can treat "no file" |
| 180 | // as a state, not a failure. |
| 181 | func readJSONFile(path string, v any) error { |
| 182 | b, err := safefile.ReadFile(path) |
| 183 | if err != nil { |
| 184 | return err |
| 185 | } |
| 186 | return json.Unmarshal(b, v) |
| 187 | } |
| 188 | |
| 189 | func readSystemResult(optDir string) (*systemResult, error) { |
| 190 | var r systemResult |
| 191 | err := readJSONFile(filepath.Join(systemRootDir(optDir), "result.json"), &r) |
| 192 | if errors.Is(err, os.ErrNotExist) { |
| 193 | return nil, nil |
| 194 | } |
| 195 | return &r, err |
| 196 | } |
| 197 | |
| 198 | func readSystemSeen(optDir string) (systemSeen, error) { |
| 199 | s := systemSeen{} |
| 200 | err := readJSONFile(filepath.Join(systemRootDir(optDir), "seen.json"), &s) |
| 201 | if errors.Is(err, os.ErrNotExist) { |
| 202 | return systemSeen{}, nil |
| 203 | } |
| 204 | if s == nil { |
| 205 | s = systemSeen{} // a literal `null` on disk unmarshals to a nil map |
| 206 | } |
| 207 | return s, err |
| 208 | } |
| 209 | |
| 210 | func readSystemApplied(optDir string) (*systemApplied, error) { |
| 211 | var a systemApplied |
| 212 | err := readJSONFile(filepath.Join(systemRootDir(optDir), "applied.json"), &a) |
| 213 | if errors.Is(err, os.ErrNotExist) { |
| 214 | return nil, nil |
| 215 | } |
| 216 | return &a, err |
| 217 | } |
| 218 | |
| 219 | func readSystemJournal(optDir string) (*systemJournal, error) { |
| 220 | var j systemJournal |
| 221 | err := readJSONFile(filepath.Join(systemRootDir(optDir), "journal.json"), &j) |
| 222 | if errors.Is(err, os.ErrNotExist) { |
| 223 | return nil, nil |
| 224 | } |
| 225 | return &j, err |
| 226 | } |
| 227 | |