carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
25const (
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).
49type 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.
58type 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
67func (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.
72func (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.
85type 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.
93type 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.
100type 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.
113func (j *systemJournal) promoted() time.Time {
114 if j.PromotedAt.IsZero() {
115 return j.StartedAt
116 }
117 return j.PromotedAt
118}
119
120var 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.
124func validSystemVersion(v string) bool { return systemVersionRe.MatchString(v) }
125
126func systemDataDir(dd string) string { return filepath.Join(dd, "system", systemComponent) }
127func 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.
134func systemCacheParts(version string) []string {
135 return []string{"system", systemComponent, version, "carlos"}
136}
137
138func systemCachePath(dd, version string) string {
139 return filepath.Join(append([]string{dd}, systemCacheParts(version)...)...)
140}
141func systemRootDir(optDir string) string { return filepath.Join(optDir, "system", systemComponent) }
142func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") }
143func 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.
148func writeDurable(path string, mode os.FileMode, fn func(io.Writer) error) (err error) {
149 return safefile.Write(path, mode, fn)
150}
151
152func 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.
162func 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
171func 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.
181func 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
189func 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
198func 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
210func 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
219func 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