carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostfiles
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "slices"
12 "sort"
13 "strings"
14 "sync"
15
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// ErrSuperseded means another writer replaced the manifest. The caller
20// stops publishing: it is no longer this host's executor, or two
21// publishers were started for one host.
22var ErrSuperseded = errors.New("hostfiles: another writer published this manifest")
23
24// Publisher stages changes and commits them as one manifest revision. It
25// is safe for concurrent use.
26type Publisher struct {
27 s release.Store
28 prefix string
29
30 mu sync.Mutex
31 man Manifest
32 etag string
33 next int64 // the last sequence number handed out; man.Seq is the last committed
34 dirty bool
35 // inflight holds, by sha256, every manifest body a failed Put may have
36 // applied since the last successful commit, with the sequence number it
37 // carried. Every one of those Puts named the same etag, so at most one
38 // of them can be in storage.
39 inflight map[string]int64
40}
41
42func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, error) {
43 if err := checkPrefix(prefix); err != nil {
44 return nil, err
45 }
46 m, etag, _, err := load(ctx, s, prefix)
47 if err != nil {
48 return nil, err
49 }
50 // A manifest written before the path cap may hold a path past it. No
51 // reader accepts it (checkManifest), and no Commit could ever write it
52 // again, so it is refused here, terminally, rather than continued into
53 // a publisher whose every commit fails. An operator replaces it.
54 if err := manifestWithinCap(m); err != nil {
55 return nil, fmt.Errorf("hostfiles: open %s: the manifest cannot be continued, an operator must replace it: %w", prefix, err)
56 }
57 return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil
58}
59
60// RestorableWithinCap is the check a caller makes before it starts work
61// that will need m restored (a move's cut, the claim's files backfill):
62// every entry path of m, live, tombstoned or skipped, within the tree's
63// length cap. A manifest that fails it is ErrTooDeep: no reader accepts
64// it, so an operator replaces it before anything that needs it starts
65// (#236, Paul's ruling of 2026-10-15).
66func RestorableWithinCap(m Manifest) error { return manifestWithinCap(m) }
67
68// manifestWithinCap holds every entry path of m to withinTreeCap, the cap
69// the readers apply: Open refuses to continue a manifest that fails it,
70// and Commit refuses to write one.
71func manifestWithinCap(m Manifest) error {
72 for path := range m.Entries {
73 if err := withinTreeCap(path); err != nil {
74 return err
75 }
76 }
77 return nil
78}
79
80// OpenAt is a publisher over the manifest bytes a caller already read and
81// checked, with the token the store answered for them: the publisher's
82// baseline and its compare-and-swap token are exactly those, never a
83// second read (Task 6 fix round 2, finding 2). A manifest replaced at the
84// key after that read makes the first Commit fail (ErrSuperseded) rather
85// than publish over bytes nobody checked.
86func OpenAt(s release.Store, prefix string, body []byte, etag string) (*Publisher, error) {
87 if s == nil {
88 return nil, errors.New("hostfiles: open: no store")
89 }
90 if err := checkPrefix(prefix); err != nil {
91 return nil, err
92 }
93 if etag == "" {
94 return nil, fmt.Errorf("hostfiles: open %s at its read bytes: no token", prefix)
95 }
96 m, err := decode(prefix, body)
97 if err != nil {
98 return nil, err
99 }
100 // The same terminal refusal as Open's: a manifest past the path cap
101 // cannot be continued.
102 if err := manifestWithinCap(m); err != nil {
103 return nil, fmt.Errorf("hostfiles: open %s: the manifest cannot be continued, an operator must replace it: %w", prefix, err)
104 }
105 return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil
106}
107
108func sum(body []byte) string {
109 h := sha256.Sum256(body)
110 return hex.EncodeToString(h[:])
111}
112
113// put writes body as the next immutable object under this publisher's own
114// prefix and returns its entry, whose Object is the full key. Callers hold
115// p.mu.
116func (p *Publisher) put(ctx context.Context, body []byte, mode uint32, torn bool) (Entry, error) {
117 sha := sum(body)
118 seq := p.next + 1
119 key := p.prefix + objectKey(seq, sha)
120 if _, err := p.s.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
121 // A conflict means this exact key exists: the same sequence number
122 // and the same hash, left by an attempt that crashed before its
123 // commit. The bytes are the ones we were about to write.
124 return Entry{}, err
125 }
126 p.next = seq
127 return Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode & 0o777, Seq: seq, Torn: torn}, nil
128}
129
130// Stage records path's content. Content identical to what is already
131// staged or committed takes no sequence number and writes nothing.
132func (p *Publisher) Stage(ctx context.Context, path string, body []byte, mode uint32, torn bool) error {
133 clean, err := CleanPath(path)
134 if err != nil {
135 return err
136 }
137 if err := withinTreeCap(clean); err != nil {
138 return err
139 }
140 p.mu.Lock()
141 defer p.mu.Unlock()
142 if cur, ok := p.man.Entries[clean]; ok && !cur.Deleted && cur.Skipped == "" &&
143 cur.SHA256 == sum(body) && cur.Mode == mode&0o777 && cur.Torn == torn {
144 return nil
145 }
146 e, err := p.put(ctx, body, mode, torn)
147 if err != nil {
148 return err
149 }
150 p.man.Entries[clean], p.dirty = e, true
151 return nil
152}
153
154// StageSkipped records that path exists but was not published, and why.
155func (p *Publisher) StageSkipped(path, why string) error {
156 clean, err := CleanPath(path)
157 if err != nil {
158 return err
159 }
160 if err := withinTreeCap(clean); err != nil {
161 return err
162 }
163 p.mu.Lock()
164 defer p.mu.Unlock()
165 if cur, ok := p.man.Entries[clean]; ok && cur.Skipped == why {
166 return nil
167 }
168 p.next++
169 p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Skipped: why}, true
170 return nil
171}
172
173// StageDelete records that path is gone. Deleting a path the manifest never
174// had, or already has as deleted, is a no-op.
175func (p *Publisher) StageDelete(path string) error {
176 clean, err := CleanPath(path)
177 if err != nil {
178 return err
179 }
180 if err := withinTreeCap(clean); err != nil {
181 return err
182 }
183 p.mu.Lock()
184 defer p.mu.Unlock()
185 if cur, ok := p.man.Entries[clean]; !ok || cur.Deleted {
186 return nil
187 }
188 p.next++
189 p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Deleted: true}, true
190 return nil
191}
192
193// StagePlatform records a platform-managed item (wake state, an env file).
194// It shares the manifest's ordering but is never written into the files
195// directory by Restore.
196func (p *Publisher) StagePlatform(ctx context.Context, name string, body []byte) error {
197 if name == "" || strings.ContainsAny(name, "/\x00") {
198 return fmt.Errorf("hostfiles: platform item %q is not a plain name", name)
199 }
200 p.mu.Lock()
201 defer p.mu.Unlock()
202 if cur, ok := p.man.Platform[name]; ok && cur.SHA256 == sum(body) {
203 return nil
204 }
205 e, err := p.put(ctx, body, 0o600, false)
206 if err != nil {
207 return err
208 }
209 p.man.Platform[name], p.dirty = e, true
210 return nil
211}
212
213// Commit publishes everything staged as one manifest revision and returns
214// its sequence number. With nothing staged it returns the current one. A
215// failed commit leaves the staged changes in place, so the caller retries;
216// ErrSuperseded is final.
217func (p *Publisher) Commit(ctx context.Context) (int64, error) {
218 p.mu.Lock()
219 defer p.mu.Unlock()
220 if !p.dirty {
221 return p.man.Seq, nil
222 }
223 // The readers refuse a manifest with a path past the cap
224 // (checkManifest), so none is written: Open refuses to continue one and
225 // Stage refuses to add one; this is the last line before the store.
226 if err := manifestWithinCap(p.man); err != nil {
227 return 0, err
228 }
229 out := p.man
230 out.Seq = p.next
231 body, err := json.Marshal(out)
232 if err != nil {
233 return 0, fmt.Errorf("hostfiles: marshal manifest: %w", err)
234 }
235 etag, err := p.s.Put(ctx, ManifestKey(p.prefix), body, p.etag)
236 if errors.Is(err, release.ErrCASConflict) {
237 // A lost response looks like this: the store applied an earlier Put,
238 // the reply never arrived, and this retry's etag is now stale. If
239 // the manifest holds exactly the bytes this Commit is writing, or
240 // the bytes an earlier failed Commit wrote, it is ours.
241 cur, curEtag, gerr := p.s.Get(ctx, ManifestKey(p.prefix))
242 if gerr != nil {
243 // Not knowing is not the same as being superseded: leave the
244 // staged state and etag alone so the next Commit repeats the check.
245 return 0, gerr
246 }
247 if bytes.Equal(cur, body) {
248 err, etag = nil, curEtag
249 } else if seq, ok := p.inflight[sum(cur)]; ok {
250 // An earlier attempt landed and later stages changed the body.
251 // Adopt it, then write the current body once on top of it.
252 p.man.Seq, p.etag, p.inflight = seq, curEtag, nil
253 etag, err = p.s.Put(ctx, ManifestKey(p.prefix), body, p.etag)
254 if errors.Is(err, release.ErrCASConflict) {
255 return 0, fmt.Errorf("%w: %s", ErrSuperseded, p.prefix)
256 }
257 } else {
258 return 0, fmt.Errorf("%w: %s", ErrSuperseded, p.prefix)
259 }
260 }
261 if err != nil {
262 if !errors.Is(err, release.ErrCASConflict) {
263 if p.inflight == nil {
264 p.inflight = map[string]int64{}
265 }
266 p.inflight[sum(body)] = out.Seq
267 }
268 return 0, err
269 }
270 p.man.Seq, p.etag, p.dirty, p.inflight = out.Seq, etag, false, nil
271 return out.Seq, nil
272}
273
274// SetOrigins sets the manifest's carried origin set (Manifest.Origins),
275// sorted and deduplicated. The same set is not a change; a different one
276// goes out with the next Commit.
277func (p *Publisher) SetOrigins(origins []string) {
278 set := map[string]bool{}
279 for _, o := range origins {
280 if o != "" {
281 set[o] = true
282 }
283 }
284 out := make([]string, 0, len(set))
285 for o := range set {
286 out = append(out, o)
287 }
288 sort.Strings(out)
289 p.mu.Lock()
290 defer p.mu.Unlock()
291 if slices.Equal(out, p.man.Origins) {
292 return
293 }
294 p.man.Origins, p.dirty = out, true
295}
296
297func (p *Publisher) Dirty() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.dirty }
298
299func (p *Publisher) Seq() int64 { p.mu.Lock(); defer p.mu.Unlock(); return p.man.Seq }
300
301// Paths lists every path the manifest holds as present: published or
302// skipped, not deleted.
303func (p *Publisher) Paths() []string {
304 p.mu.Lock()
305 defer p.mu.Unlock()
306 var out []string
307 for path, e := range p.man.Entries {
308 if !e.Deleted {
309 out = append(out, path)
310 }
311 }
312 sort.Strings(out)
313 return out
314}
315
316// pending is the last sequence number handed out, committed or not.
317func (p *Publisher) pending() int64 {
318 p.mu.Lock()
319 defer p.mu.Unlock()
320 return p.next
321}
322