carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package boxvolumes records a platform box's block device mappings while
2// it runs (region HA spec 1 §5.9, ruling H19), and decides from those
3// records whether a terminated box's park-local chores may be discharged
4// without retirement (claim D5's destroyed-local exception, L3).
5//
6// Nothing here is a termination fence, and nothing about execution,
7// admission, relocation or recovery reads it (spec §8, B4-1). A record that
8// is wrong or missing can leave a disk unretired for an operator; it can
9// never admit a writer.
10//
11// This package builds, writes and reads records. The loop that describes
12// every enrolled box in the region needs the roster's enrollments, and the
13// discharge CAS needs L3: both wait for the claim.
14package boxvolumes
15
16import (
17 "bytes"
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "slices"
24 "sort"
25 "strings"
26 "time"
27
28 "github.com/aws/aws-sdk-go-v2/aws"
29 awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
30 "github.com/aws/aws-sdk-go-v2/service/ec2"
31
32 "github.com/carlosframework/platform/internal/boxid"
33 "github.com/carlosframework/platform/internal/release"
34)
35
36const (
37 Prefix = "control/box-volumes/"
38
39 // RefreshEvery is the longest a running box goes without a new record
40 // when nothing changes (spec §5.9: "at least once an hour").
41 RefreshEvery = time.Hour
42 // EnrollmentWindow is how soon after enrollment the first record must
43 // be taken for it to stand for the launch-time mappings.
44 EnrollmentWindow = time.Hour
45 // LastRecordWindow is the most the newest record may predate the
46 // termination observation (controller ruling, 2026-10-06). An older one
47 // cannot speak for a volume attached after it.
48 LastRecordWindow = 2 * time.Hour
49
50 keyTime = "2006-01-02T15-04-05.000000000Z"
51)
52
53// Device is one EBS volume in the instance's block device mappings.
54type Device struct {
55 Device string `json:"device"`
56 VolumeID string `json:"volume_id"`
57 DeleteOnTermination bool `json:"delete_on_termination"`
58 Status string `json:"status"`
59}
60
61// WrittenBy names the regional worker that made the read.
62type WrittenBy struct {
63 Box string `json:"box"`
64 Enrollment string `json:"enrollment"`
65}
66
67// Record is §5.9's volume record,
68// control/box-volumes/<enrollment>/<observed_at>.json.
69type Record struct {
70 V int `json:"v"`
71 Box string `json:"box"`
72 Enrollment string `json:"enrollment"`
73 Cloud boxid.Cloud `json:"cloud"`
74 InstanceState string `json:"instance_state"`
75 BlockDevices []Device `json:"block_devices"`
76 RemovedVolumes []string `json:"removed_volumes"`
77 ObservedAt string `json:"observed_at"`
78 RequestID string `json:"request_id"`
79 WrittenBy WrittenBy `json:"written_by"`
80}
81
82// Entry is the roster entry a record describes.
83type Entry struct {
84 Box string
85 Enrollment string
86 Cloud boxid.Cloud
87}
88
89// Observation is one DescribeInstances read of one enrolled box.
90type Observation struct {
91 State string
92 Devices []Device
93 RequestID string
94}
95
96var ErrNotRecordable = errors.New("boxvolumes: not a recordable observation")
97
98// ValidEnrollment reports whether e can be a key segment: 1 to 128 of
99// [A-Za-z0-9._-], never "." or "..".
100func ValidEnrollment(e string) bool {
101 if e == "" || len(e) > 128 || e == "." || e == ".." {
102 return false
103 }
104 for _, r := range e {
105 ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-'
106 if !ok {
107 return false
108 }
109 }
110 return true
111}
112
113// Key is where a record observed at t lives. The time is fixed-width UTC,
114// so keys sort in observation order.
115func Key(enrollment string, t time.Time) string {
116 return Prefix + enrollment + "/" + t.UTC().Format(keyTime) + ".json"
117}
118
119// recordable is every state in which EC2 still reports the instance's
120// mappings. "terminated" is not one: its BlockDeviceMappings is empty (the
121// dev test of 2026-10-06), which would read as every volume removed.
122var recordable = map[string]bool{"pending": true, "running": true, "shutting-down": true, "stopping": true, "stopped": true}
123
124// Observe reads the entry's instance out of a DescribeInstances reply (see
125// termfence.DescribeInstance for the call). It refuses a reply that does
126// not name the instance, names it under another owner, or reports a state
127// whose mappings are not the instance's.
128func Observe(out *ec2.DescribeInstancesOutput, c boxid.Cloud) (Observation, error) {
129 if out == nil || !c.Complete() {
130 return Observation{}, fmt.Errorf("%w: no reply, or no proven cloud", ErrNotRecordable)
131 }
132 var o Observation
133 if id, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); ok {
134 o.RequestID = id
135 }
136 for _, r := range out.Reservations {
137 for _, inst := range r.Instances {
138 if aws.ToString(inst.InstanceId) != c.InstanceID {
139 continue
140 }
141 if aws.ToString(r.OwnerId) != c.Account {
142 return Observation{}, fmt.Errorf("%w: %s is owned by %q, the roster says %q", ErrNotRecordable, c.InstanceID, aws.ToString(r.OwnerId), c.Account)
143 }
144 if inst.State != nil {
145 o.State = string(inst.State.Name)
146 }
147 if !recordable[o.State] {
148 return Observation{}, fmt.Errorf("%w: state %q", ErrNotRecordable, o.State)
149 }
150 for _, m := range inst.BlockDeviceMappings {
151 if m.Ebs == nil {
152 continue // instance store dies with the instance
153 }
154 o.Devices = append(o.Devices, Device{Device: aws.ToString(m.DeviceName), VolumeID: aws.ToString(m.Ebs.VolumeId),
155 DeleteOnTermination: aws.ToBool(m.Ebs.DeleteOnTermination), Status: string(m.Ebs.Status)})
156 }
157 sort.Slice(o.Devices, func(i, j int) bool { return o.Devices[i].VolumeID < o.Devices[j].VolumeID })
158 return o, nil
159 }
160 }
161 return Observation{}, fmt.Errorf("%w: the reply does not name %s", ErrNotRecordable, c.InstanceID)
162}
163
164func volumeIDs(ds []Device) []string {
165 out := make([]string, 0, len(ds))
166 for _, d := range ds {
167 out = append(out, d.VolumeID)
168 }
169 return out
170}
171
172func removed(prev, cur []Device) []string {
173 have := volumeIDs(cur)
174 var out []string
175 for _, id := range volumeIDs(prev) {
176 if !slices.Contains(have, id) {
177 out = append(out, id)
178 }
179 }
180 sort.Strings(out)
181 return out
182}
183
184// Next decides whether o, made by writer at now, is a new record after
185// prev (nil when the enrollment has none). It is one when there is no
186// record yet, when the mapping or the instance state differs from prev,
187// and otherwise once RefreshEvery has passed.
188func Next(prev *Record, e Entry, writer WrittenBy, o Observation, now time.Time) (Record, bool) {
189 rec := Record{V: 1, Box: e.Box, Enrollment: e.Enrollment, Cloud: e.Cloud, InstanceState: o.State,
190 BlockDevices: o.Devices, RemovedVolumes: []string{}, ObservedAt: now.UTC().Format(time.RFC3339Nano),
191 RequestID: o.RequestID, WrittenBy: writer}
192 if rec.BlockDevices == nil {
193 rec.BlockDevices = []Device{}
194 }
195 if prev == nil {
196 return rec, true
197 }
198 if r := removed(prev.BlockDevices, o.Devices); len(r) > 0 {
199 rec.RemovedVolumes = r
200 }
201 if !slices.Equal(prev.BlockDevices, rec.BlockDevices) || prev.InstanceState != o.State {
202 return rec, true
203 }
204 at, err := time.Parse(time.RFC3339Nano, prev.ObservedAt)
205 return rec, err != nil || now.Sub(at) >= RefreshEvery
206}
207
208// Write stores rec create-only at its key. Records are never replaced or
209// deleted (spec §6); the same bytes written twice are one record.
210func Write(ctx context.Context, s release.Store, rec Record) (string, error) {
211 if !ValidEnrollment(rec.Enrollment) {
212 return "", fmt.Errorf("boxvolumes: %q is not an enrollment", rec.Enrollment)
213 }
214 at, err := time.Parse(time.RFC3339Nano, rec.ObservedAt)
215 if err != nil {
216 return "", fmt.Errorf("boxvolumes: observed_at: %w", err)
217 }
218 body, err := json.Marshal(rec)
219 if err != nil {
220 return "", err
221 }
222 key := Key(rec.Enrollment, at)
223 _, err = s.Put(ctx, key, body, "")
224 if errors.Is(err, release.ErrCASConflict) {
225 cur, _, gerr := s.Get(ctx, key)
226 if gerr == nil && bytes.Equal(cur, body) {
227 return key, nil
228 }
229 return "", fmt.Errorf("boxvolumes: %s already holds another record: %w", key, err)
230 }
231 return key, err
232}
233
234func decode(body []byte) (Record, error) {
235 dec := json.NewDecoder(bytes.NewReader(body))
236 dec.DisallowUnknownFields()
237 var rec Record
238 if err := dec.Decode(&rec); err != nil {
239 return Record{}, err
240 }
241 if _, err := dec.Token(); err != io.EOF {
242 return Record{}, errors.New("trailing data")
243 }
244 return rec, nil
245}
246
247// List returns every record stored for an enrollment, in key order. Any
248// record that cannot be read or parsed is an error, never a gap: a gap
249// could hide the record that shows a removal.
250func List(ctx context.Context, s release.Store, enrollment string) ([]Record, error) {
251 if !ValidEnrollment(enrollment) {
252 return nil, fmt.Errorf("boxvolumes: %q is not an enrollment", enrollment)
253 }
254 keys, err := s.List(ctx, Prefix+enrollment+"/")
255 if err != nil {
256 return nil, err
257 }
258 sort.Strings(keys)
259 out := make([]Record, 0, len(keys))
260 for _, k := range keys {
261 if !strings.HasSuffix(k, ".json") {
262 continue
263 }
264 body, _, err := s.Get(ctx, k)
265 if err != nil {
266 return nil, fmt.Errorf("boxvolumes: %s: %w", k, err)
267 }
268 rec, err := decode(body)
269 if err != nil {
270 return nil, fmt.Errorf("boxvolumes: %s: %w", k, err)
271 }
272 out = append(out, rec)
273 }
274 return out, nil
275}
276
277// incomplete says why a stored record is not evidence of a running
278// instance's mappings, or "" when it is. Write can store such a record
279// (Observe records every state in recordable, and a volume's status as
280// EC2 reports it), and a stored one may also come from an older writer, a
281// hand edit or a bug; any of them keeps the chores open rather than being
282// read as "nothing attached".
283//
284// A volume must be attached or attaching, with one exception: while the
285// instance is shutting down EC2 reports its DeleteOnTermination volumes
286// as detaching on their way to deletion, so in a shutting-down record a
287// detaching volume whose flag is true is still evidence. A detaching
288// volume with a false flag, or in any other state, may outlive the box.
289func incomplete(r Record) string {
290 if !recordable[r.InstanceState] {
291 return fmt.Sprintf("has state %q, which carries no mapping", r.InstanceState)
292 }
293 if len(r.BlockDevices) == 0 {
294 return "lists no EBS volume" // a platform box always has its root volume
295 }
296 for _, d := range r.BlockDevices {
297 if d.VolumeID == "" || d.Device == "" {
298 return "lists a volume with no id or device"
299 }
300 dying := r.InstanceState == "shutting-down" && d.DeleteOnTermination && d.Status == "detaching"
301 if d.Status != "attached" && d.Status != "attaching" && !dying {
302 return fmt.Sprintf("lists volume %s with status %q", d.VolumeID, d.Status)
303 }
304 }
305 return ""
306}
307
308// Verdict is whether a terminated box's park-local chores may be
309// discharged without retirement, and if not, why.
310type Verdict struct {
311 Discharge bool
312 Reason string
313}
314
315// Discharge decides from records (as List returns them) whether every EBS
316// volume e's instance had attached, from the first record taken within
317// EnrollmentWindow of enrolledAt to the last, taken within LastRecordWindow
318// of terminatedAt (the fence's observed_at), was DeleteOnTermination, with
319// none ever removed (spec §5.9, H19). Every record is checked, none
320// skipped: one with another version, enrollment or cloud identity, one
321// that is incomplete or carries no mapping, a volume not attached or
322// attaching (bar a DeleteOnTermination one detaching as the instance
323// shuts down), a record outside enrolledAt to terminatedAt, or a gap between
324// records over LastRecordWindow keeps the chores open, as do missing or
325// misordered times. Anything short of that keeps the chores open for an
326// operator, which costs cleanup and never recovery.
327func Discharge(records []Record, e Entry, enrolledAt, terminatedAt time.Time) Verdict {
328 no := func(format string, a ...any) Verdict { return Verdict{Reason: fmt.Sprintf(format, a...)} }
329 if !e.Cloud.Complete() || e.Enrollment == "" {
330 return no("the roster entry has no proven cloud")
331 }
332 if enrolledAt.IsZero() || terminatedAt.IsZero() || terminatedAt.Before(enrolledAt) {
333 return no("the enrollment and termination times are missing or out of order")
334 }
335 type stamped struct {
336 at time.Time
337 rec Record
338 }
339 var mine []stamped
340 for _, r := range records {
341 // Every record listed under this enrollment's prefix is checked,
342 // none skipped: one that is not this enrollment's, this instance's
343 // or a version this binary reads could be the adverse record.
344 if r.V != 1 {
345 return no("a record for %s has version %d", e.Enrollment, r.V)
346 }
347 if r.Enrollment != e.Enrollment {
348 return no("a record listed for %s names enrollment %q", e.Enrollment, r.Enrollment)
349 }
350 if r.Cloud != e.Cloud {
351 return no("a record for %s names another cloud identity", e.Enrollment)
352 }
353 if reason := incomplete(r); reason != "" {
354 return no("the record observed at %q %s", r.ObservedAt, reason)
355 }
356 at, err := time.Parse(time.RFC3339Nano, r.ObservedAt)
357 if err != nil {
358 return no("record with unreadable observed_at %q", r.ObservedAt)
359 }
360 mine = append(mine, stamped{at, r})
361 }
362 if len(mine) == 0 {
363 return no("no volume record for %s", e.Enrollment)
364 }
365 sort.SliceStable(mine, func(i, j int) bool { return mine[i].at.Before(mine[j].at) })
366 if first := mine[0].at; first.After(enrolledAt.Add(EnrollmentWindow)) {
367 return no("the first record (%s) is more than %s after enrollment", first.Format(time.RFC3339), EnrollmentWindow)
368 }
369 if last := mine[len(mine)-1].at; terminatedAt.Sub(last) > LastRecordWindow {
370 return no("the newest record (%s) is more than %s before the termination", last.Format(time.RFC3339), LastRecordWindow)
371 }
372 for i, s := range mine {
373 if s.at.Before(enrolledAt) || s.at.After(terminatedAt) {
374 return no("the record observed at %s lies outside enrollment to termination", s.rec.ObservedAt)
375 }
376 if i > 0 && s.at.Sub(mine[i-1].at) > LastRecordWindow {
377 return no("no record between %s and %s, more than %s", mine[i-1].rec.ObservedAt, s.rec.ObservedAt, LastRecordWindow)
378 }
379 for _, d := range s.rec.BlockDevices {
380 if !d.DeleteOnTermination {
381 return no("volume %s was not DeleteOnTermination at %s", d.VolumeID, s.rec.ObservedAt)
382 }
383 }
384 if len(s.rec.RemovedVolumes) > 0 {
385 return no("volumes %v were removed by %s", s.rec.RemovedVolumes, s.rec.ObservedAt)
386 }
387 if i > 0 {
388 if r := removed(mine[i-1].rec.BlockDevices, s.rec.BlockDevices); len(r) > 0 {
389 return no("volumes %v left the mapping by %s", r, s.rec.ObservedAt)
390 }
391 }
392 }
393 return Verdict{Discharge: true}
394}
395