carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostactors
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostclaim"
13 "github.com/carlosframework/platform/internal/hostfloor"
14 "github.com/carlosframework/platform/internal/release"
15 "github.com/carlosframework/platform/internal/status"
16 "github.com/carlosframework/platform/internal/termfence"
17)
18
19// plan9World is a published deployment with three active boxes: a and b
20// hold an executable activation reservation, w is a regional fence writer.
21type plan9World struct {
22 d Directory
23 sg release.Signer
24 floor hostfloor.Verified
25 now time.Time
26 hash string
27 target string
28}
29
30const plan9Activation = "act"
31
32func plan9Fixture(t *testing.T, edit func(*Roster)) (plan9World, Observed) {
33 t.Helper()
34 ctx := context.Background()
35 pub, key, err := ed25519.GenerateKey(nil)
36 if err != nil {
37 t.Fatal(err)
38 }
39 store := release.NewDirStore(t.TempDir())
40 sg := release.RootSigner(key)
41 w := plan9World{sg: sg, now: time.Now().UTC(), hash: strings.Repeat("a", 64), target: strings.Repeat("b", 64)}
42 rel := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.0.0", "linux-amd64"), SHA256: w.hash}}}
43 if _, err := release.PromoteSystem(ctx, store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
44 t.Fatal(err)
45 }
46 wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
47 if err != nil {
48 t.Fatal(err)
49 }
50 if err := release.PublishSystemIdentity(ctx, store, pub, wire); err != nil {
51 t.Fatal(err)
52 }
53 if err := hostfloor.Publish(ctx, store, sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
54 t.Fatal(err)
55 }
56 w.d = Directory{Store: store, PublicKey: pub, Bucket: "bucket"}
57 if w.floor, err = hostfloor.Read(ctx, store, pub, "bucket"); err != nil {
58 t.Fatal(err)
59 }
60 r := Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Publication: "published", FloorSHA256: w.floor.Digest, Boxes: map[string]Box{}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}}
61 for i, ref := range []string{"a", "b", "w"} {
62 r.Boxes[ref] = Box{Enrollment: strings.Repeat(string(rune('1'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", Cloud: Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("e", 64), State: "active", LossIDs: []string{}}
63 }
64 h := Reservation{ID: plan9Activation, FloorSHA256: w.floor.Digest, Actors: map[string]string{}, Releases: map[string]Report{}, Hashes: map[string][]string{}}
65 for _, ref := range []string{"a", "b"} {
66 en := r.Boxes[ref].Enrollment
67 h.Actors["box:"+ref] = en
68 h.Releases["box:"+ref] = Report{Enrollment: en}
69 h.Hashes["box:"+ref] = []string{w.hash, w.target, w.hash}
70 }
71 r.Activations[plan9Activation] = h
72 if edit != nil {
73 edit(&r)
74 }
75 raw, err := release.SignRootEnvelope(sg, r)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if _, err := store.Put(ctx, Key, raw, ""); err != nil {
80 t.Fatal(err)
81 }
82 w.report(t, "a", true)
83 o, err := w.d.Read(ctx)
84 if err != nil {
85 t.Fatal(err)
86 }
87 return w, o
88}
89
90// report publishes ref's private status as a box that finished (or did not
91// finish) the reserved activation on its current image.
92func (w plan9World) report(t *testing.T, ref string, complete bool) {
93 t.Helper()
94 o, err := w.d.Read(context.Background())
95 if err != nil {
96 t.Fatal(err)
97 }
98 b := o.Roster.Boxes[ref]
99 box := status.Box{GeneratedAt: w.now, Metrics: &status.BoxMetrics{Enrollment: b.Enrollment, InstanceID: b.Cloud.InstanceID, Release: "v1.0.0", ReleaseTag: "v1.0.0", Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: true, SystemChannel: "stable", ActivationID: plan9Activation, ActivationComplete: complete}}
100 raw, err := json.Marshal(box)
101 if err != nil {
102 t.Fatal(err)
103 }
104 _, etag, _ := w.d.Store.Get(context.Background(), "status/boxes/"+ref+".json")
105 if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+ref+".json", raw, etag); err != nil {
106 t.Fatal(err)
107 }
108}
109
110// fence creates target's termination fence, observed at the given time and
111// written by writer, exactly as a regional worker persists it.
112func (w plan9World) fence(t *testing.T, target, writer string, at time.Time, state string) {
113 t.Helper()
114 o, err := w.d.Read(context.Background())
115 if err != nil {
116 t.Fatal(err)
117 }
118 b, by := o.Roster.Boxes[target], o.Roster.Boxes[writer]
119 f := termfence.Record{V: 1, Box: target, Enrollment: b.Enrollment, Cloud: b.Cloud, State: state, ObservedAt: at.UTC().Format(time.RFC3339Nano), Via: termfence.ViaDescribe, RequestID: "request", WrittenBy: termfence.WrittenBy{Box: writer, Enrollment: by.Enrollment}}
120 raw, err := json.Marshal(f)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if err := release.CreateExact(context.Background(), w.d.Store, "control/box-fences/"+b.Enrollment+".json", raw); err != nil {
125 t.Fatal(err)
126 }
127}
128
129// quarantine marks ref quarantined in the roster edit and returns the loss
130// record a root-signed operator declaration would have created for it.
131func plan9Quarantine(r *Roster, ref, loss string) {
132 b := r.Boxes[ref]
133 b.State = "quarantined"
134 b.QuarantineRevision = 1
135 b.LossIDs = []string{loss}
136 r.Boxes[ref] = b
137}
138
139func (w plan9World) loss(t *testing.T, ref, id string, fencedAt time.Time) {
140 t.Helper()
141 o, err := w.d.Read(context.Background())
142 if err != nil {
143 t.Fatal(err)
144 }
145 b := o.Roster.Boxes[ref]
146 record := hostclaim.LossRecord{
147 V: 1, ID: id, State: "prepared", Actor: "operator", AcceptedAt: fencedAt,
148 Host: "shop.bab.oncarlos.com", HolderIncarnation: strings.Repeat("a", 64), HolderStamp: 3, HolderSession: 2, ExpectedClaimGen: 4, ExpectedClaimETag: `"claim"`,
149 OpID: "op", OpKind: hostclaim.KindMoving, OpPhase: "precommit", Box: ref, Enrollment: b.Enrollment, QuarantineRevision: b.QuarantineRevision,
150 CloudObservation: hostclaim.LossCloudObservation{ObservedAt: fencedAt, Account: "123", Region: "eu-west-1", InstanceID: b.Cloud.InstanceID, State: "stopped", RequestID: "request-1"},
151 OperatorAttestation: hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true},
152 FencedAt: fencedAt,
153 ConfirmedConsequences: hostclaim.LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`), TimersPotentiallyLost: true},
154 Timers: hostclaim.TimerLoss{Knowledge: hostclaim.TimersUnknown},
155 }
156 if _, err := hostclaim.CreateLossRecord(context.Background(), w.d.Store, w.sg, record); err != nil {
157 t.Fatal(err)
158 }
159}
160
161// I-4 and D7: a held activation is released only by verified completion of
162// every reserved actor, except an actor excluded through the valid
163// enrollment-qualified termination fence or its durable quarantine
164// authority. Silence, unreadable fence authority and an invalid fence never
165// exclude.
166func TestPlan9ReleaseActivationExcludesOnlyFencedOrQuarantinedActors(t *testing.T) {
167 for _, tc := range []struct {
168 name string
169 released bool
170 }{
171 {"silent", false},
172 {"incomplete", false},
173 {"fenced", true},
174 {"fenced-other-incomplete", false},
175 {"fence-nonterminal", false},
176 {"fence-self-written", false},
177 {"fence-unreadable", false},
178 {"quarantined", true},
179 {"quarantined-loss-missing", false},
180 {"both-complete", true},
181 } {
182 t.Run(tc.name, func(t *testing.T) {
183 ctx := context.Background()
184 var edit func(*Roster)
185 if strings.HasPrefix(tc.name, "quarantined") {
186 edit = func(r *Roster) { plan9Quarantine(r, "b", "loss-b") }
187 }
188 w, _ := plan9Fixture(t, edit)
189 switch tc.name {
190 case "incomplete":
191 w.report(t, "b", false)
192 case "fenced":
193 w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
194 case "fenced-other-incomplete":
195 w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
196 w.report(t, "a", false)
197 case "fence-nonterminal":
198 w.fence(t, "b", "w", w.now.Add(-time.Minute), "stopped")
199 case "fence-self-written":
200 w.fence(t, "b", "b", w.now.Add(-time.Minute), "terminated")
201 case "fence-unreadable":
202 w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
203 w.d.Store = brokenFenceStore{w.d.Store}
204 case "quarantined":
205 w.loss(t, "b", "loss-b", w.now.Add(-time.Hour))
206 case "both-complete":
207 w.report(t, "b", true)
208 }
209 err := w.d.ReleaseActivation(ctx, w.sg, plan9Activation, w.now)
210 after, readErr := w.d.Read(ctx)
211 if readErr != nil {
212 t.Fatal(readErr)
213 }
214 _, held := after.Roster.Activations[plan9Activation]
215 if tc.released {
216 if err != nil || held {
217 t.Fatalf("%s: release = %v, held = %v; want released", tc.name, err, held)
218 }
219 return
220 }
221 if err == nil || !held {
222 t.Fatalf("%s: release = %v, held = %v; want refusal and a held reservation", tc.name, err, held)
223 }
224 })
225 }
226}
227
228// M1-5: require a currently active and unfenced writer.
229func TestPlan9FenceWriterMustBeActiveAndUnfencedNow(t *testing.T) {
230 observed := time.Now().UTC().Add(-time.Hour)
231 for _, tc := range []struct {
232 name string
233 valid bool
234 }{
235 {"active-writer", true},
236 {"self-written", false},
237 {"writer-fenced-before", false},
238 {"writer-fenced-same-instant", false},
239 {"writer-fenced-after", false},
240 {"writer-fence-malformed", false},
241 {"writer-fence-unreadable", false},
242 {"writer-quarantined-before", false},
243 {"writer-quarantined-after", false},
244 {"writer-quarantined-loss-missing", false},
245 } {
246 t.Run(tc.name, func(t *testing.T) {
247 ctx := context.Background()
248 var edit func(*Roster)
249 if strings.HasPrefix(tc.name, "writer-quarantined") {
250 edit = func(r *Roster) { plan9Quarantine(r, "w", "loss-w") }
251 }
252 w, _ := plan9Fixture(t, edit)
253 writer := "w"
254 if tc.name == "self-written" {
255 writer = "b"
256 }
257 w.fence(t, "b", writer, observed, "terminated")
258 o, err := w.d.Read(ctx)
259 if err != nil {
260 t.Fatal(err)
261 }
262 writerFence := "control/box-fences/" + o.Roster.Boxes["w"].Enrollment + ".json"
263 switch tc.name {
264 case "writer-fenced-before":
265 w.fence(t, "w", "a", observed.Add(-time.Minute), "terminated")
266 case "writer-fenced-same-instant":
267 w.fence(t, "w", "a", observed, "terminated")
268 case "writer-fenced-after":
269 w.fence(t, "w", "a", observed.Add(time.Minute), "terminated")
270 case "writer-fence-malformed":
271 if err := release.CreateExact(ctx, w.d.Store, writerFence, []byte("{")); err != nil {
272 t.Fatal(err)
273 }
274 case "writer-fence-unreadable":
275 w.d.Store = plan9KeyFailStore{Store: w.d.Store, key: writerFence}
276 case "writer-quarantined-before":
277 w.loss(t, "w", "loss-w", observed.Add(-time.Minute))
278 case "writer-quarantined-after":
279 w.loss(t, "w", "loss-w", observed.Add(time.Minute))
280 }
281 fenced, err := w.d.TerminationFence(ctx, o, "b", time.Now())
282 if tc.valid != (err == nil && fenced) {
283 t.Fatalf("%s: fenced = %v, err = %v; want valid = %v", tc.name, fenced, err, tc.valid)
284 }
285 if !tc.valid && fenced {
286 t.Fatalf("%s: invalid evidence excluded the box", tc.name)
287 }
288 })
289 }
290}
291
292type plan9KeyFailStore struct {
293 release.Store
294 key string
295}
296
297func (s plan9KeyFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
298 if key == s.key {
299 return nil, "", errors.New("writer fence unavailable")
300 }
301 return s.Store.Get(ctx, key)
302}
303
304// I2: binding a census key rewrites the roster outside Replace. It must
305// stay behind the published floor (older binaries strict-decode the roster
306// and would refuse census_public_key) and bind only an active, unfenced
307// enrollment, read fresh rather than from a caller-supplied copy.
308