carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package dbverify is the fleet's restore-drill record: which databases have
2// been PROVEN restorable, when, and which could not be decided.
3//
4// It exists as its own package for the reason internal/usage does — the
5// writer (the box-side `carlos ops restore-verify` pass) and any future
6// reader (the console) must agree on one shape and one key without either
7// importing the other's machinery.
8//
9// The vocabulary is deliberately three-valued, following
10// usage.AppStorage.StoreUnswept: a replica is `ok`, `failed`, or
11// `unverified` — never a silent absence and never a 0/false that reads as
12// "fine". A pass that could not reach S3 has learned NOTHING about the
13// replica, and saying so is the whole point of keeping this record.
14package dbverify
15
16import (
17 "context"
18 "encoding/json"
19 "errors"
20 "fmt"
21 "os"
22 "time"
23
24 "github.com/carlosframework/platform/internal/release"
25)
26
27// Key is where the record lives in the deployment bucket, beside
28// control/serial and control/registry-snapshot.json.
29const Key = "control/restore-verify.json"
30
31// Verdict is one database's standing.
32type Verdict struct {
33 // OK is true only when the replica was actually restored and passed its
34 // integrity check on this box, at VerifiedAt.
35 OK bool `json:"ok"`
36 // Unverified marks "we could not tell" — an S3 error, a prefix that has
37 // never been replicated to, or a host the run's cap did not reach. It is
38 // never a failure and never a pass.
39 Unverified bool `json:"unverified,omitempty"`
40 // VerifiedAt is when this verdict was reached — NOT when the replica was
41 // written. An old timestamp on an ok verdict is itself a finding.
42 VerifiedAt time.Time `json:"verified_at"`
43 // Detail carries the failure or the reason it could not be decided.
44 Detail string `json:"detail,omitempty"`
45}
46
47// Record is the whole box's standing at CheckedAt.
48type Record struct {
49 V int `json:"v"` // 1
50 Box string `json:"box"`
51 CheckedAt time.Time `json:"checked_at"`
52 Hosts map[string]Verdict `json:"hosts"`
53}
54
55// Merge folds this run's verdicts into the prior record. prior may be nil.
56//
57// Two rules, both about not overstating coverage:
58//
59// - A capped run visits only some hosts, so untouched hosts KEEP their
60// previous verdict — with its old timestamp, which is what makes staleness
61// visible rather than hidden behind a fresh CheckedAt.
62// - A host no longer in `fleet` is DROPPED. A retired instance's last "ok"
63// would otherwise sit in the record forever, padding the coverage of a
64// database that no longer exists.
65func Merge(prior *Record, box string, now time.Time, fleet []string, results map[string]Verdict) *Record {
66 rec := &Record{V: 1, Box: box, CheckedAt: now.UTC(), Hosts: map[string]Verdict{}}
67 inFleet := map[string]bool{}
68 for _, h := range fleet {
69 inFleet[h] = true
70 }
71 if prior != nil {
72 for host, v := range prior.Hosts {
73 if inFleet[host] {
74 rec.Hosts[host] = v
75 }
76 }
77 }
78 for host, v := range results {
79 if !inFleet[host] {
80 continue
81 }
82 rec.Hosts[host] = v
83 }
84 return rec
85}
86
87// Failures counts the verdicts that are an alarm — restored and damaged.
88// Unverified rows are deliberately not counted here: they are a coverage
89// problem, not a corruption one, and conflating them would either cry wolf or
90// train operators to ignore a red run.
91func (r *Record) Failures() int {
92 n := 0
93 for _, v := range r.Hosts {
94 if !v.OK && !v.Unverified {
95 n++
96 }
97 }
98 return n
99}
100
101// Unverified counts the verdicts the pass could not decide.
102func (r *Record) Unverified() int {
103 n := 0
104 for _, v := range r.Hosts {
105 if v.Unverified {
106 n++
107 }
108 }
109 return n
110}
111
112// Load reads the current record. A missing key is (nil, nil) — a deployment
113// that has never run the drill, not an error.
114func Load(ctx context.Context, s release.Store) (*Record, error) {
115 body, _, err := s.Get(ctx, Key)
116 if errors.Is(err, os.ErrNotExist) {
117 return nil, nil
118 }
119 if err != nil {
120 return nil, err
121 }
122 var rec Record
123 if err := json.Unmarshal(body, &rec); err != nil {
124 return nil, fmt.Errorf("parse %s: %w", Key, err)
125 }
126 return &rec, nil
127}
128
129// RemoveHost drops host's verdict from the record: a moved instance's
130// replica is no longer this fleet's to verify where it was (moves spec
131// §6.3, §7.6 local). The load, the delete and the put run inside each CAS
132// attempt (moves Plan 2 ruling G31), so a record another writer changed
133// between the read and the write is re-read, never overwritten with a stale
134// body. No record, or no entry for the host, is nothing to do.
135func RemoveHost(ctx context.Context, s release.Store, host string) error {
136 for attempt := 0; attempt < 3; attempt++ {
137 body, etag, err := s.Get(ctx, Key)
138 if errors.Is(err, os.ErrNotExist) {
139 return nil
140 }
141 if err != nil {
142 return fmt.Errorf("get %s: %w", Key, err)
143 }
144 var rec Record
145 if err := json.Unmarshal(body, &rec); err != nil {
146 return fmt.Errorf("parse %s: %w", Key, err)
147 }
148 if _, ok := rec.Hosts[host]; !ok {
149 return nil
150 }
151 delete(rec.Hosts, host)
152 next, err := json.Marshal(&rec)
153 if err != nil {
154 return err
155 }
156 if _, err := s.Put(ctx, Key, next, etag); err == nil {
157 return nil
158 } else if !errors.Is(err, release.ErrCASConflict) {
159 return fmt.Errorf("put %s: %w", Key, err)
160 }
161 }
162 return fmt.Errorf("put %s: lost three CAS races", Key)
163}
164
165// Publish writes rec, replacing whatever is there. Three attempts on a lost
166// CAS race, the recipe usage.UpsertEdgeDay and host.putReplacing both use.
167func Publish(ctx context.Context, s release.Store, rec *Record) error {
168 body, err := json.Marshal(rec)
169 if err != nil {
170 return err
171 }
172 for attempt := 0; attempt < 3; attempt++ {
173 _, etag, err := s.Get(ctx, Key)
174 switch {
175 case errors.Is(err, os.ErrNotExist):
176 etag = ""
177 case err != nil:
178 return fmt.Errorf("get %s: %w", Key, err)
179 }
180 if _, err := s.Put(ctx, Key, body, etag); err == nil {
181 return nil
182 } else if !errors.Is(err, release.ErrCASConflict) {
183 return fmt.Errorf("put %s: %w", Key, err)
184 }
185 }
186 return fmt.Errorf("put %s: lost three CAS races", Key)
187}
188