carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/hostactors"
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17 "github.com/carlosframework/platform/internal/seeds"
18)
19
20// activation is a deployment at the moment claim activation publishes its
21// backfill: a signed roster of two boxes, the collecting manifest (the
22// record-writer freeze), and one signed census report per box.
23type activation struct {
24 t *testing.T
25 ctx context.Context
26 f *fixture
27 sg release.Signer
28 d hostactors.Directory
29 roster hostactors.Roster
30 boxKeys map[string]ed25519.PrivateKey
31 records []Record
32}
33
34func newActivation(t *testing.T) *activation {
35 t.Helper()
36 a := &activation{t: t, ctx: context.Background(), f: newFixture(t), boxKeys: map[string]ed25519.PrivateKey{}}
37 pub, key, err := ed25519.GenerateKey(rand.Reader)
38 if err != nil {
39 t.Fatal(err)
40 }
41 a.sg = release.RootSigner(key)
42 a.roster = hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Regions: map[string]hostactors.Region{"ie": {}},
43 Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
44 for i, ref := range []string{"box-a", "box-b"} {
45 boxPub, boxKey, err := ed25519.GenerateKey(rand.Reader)
46 if err != nil {
47 t.Fatal(err)
48 }
49 a.boxKeys[ref] = boxKey
50 a.roster.Boxes[ref] = hostactors.Box{CensusPublicKey: boxPub, Enrollment: strings.Repeat(string(rune('a'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref,
51 Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
52 }
53 raw, err := release.SignRootEnvelope(a.sg, a.roster)
54 if err != nil {
55 t.Fatal(err)
56 }
57 if _, err := a.f.store.Put(a.ctx, hostactors.Key, raw, ""); err != nil {
58 t.Fatal(err)
59 }
60 a.d = hostactors.Directory{Store: a.f.store, PublicKey: pub, Bucket: "bucket"}
61 return a
62}
63
64// record writes one instance record, with seed as its seed (nil for none).
65func (a *activation) record(host string, seed *provision.SeedSource) {
66 a.t.Helper()
67 r, err := PrepareInstance(provision.Record{Schema: 1, Account: "bab", App: "shop", Host: host, Slug: strings.SplitN(host, ".", 2)[0],
68 Channel: "edge", Region: "ie", Acquisition: "acquisition", Seed: seed}, a.f.now, "ie")
69 if err != nil {
70 a.t.Fatal(err)
71 }
72 if _, err := a.f.store.Put(a.ctx, r.Holder.Key, r.Body, ""); err != nil {
73 a.t.Fatal(err)
74 }
75 if r, err = ReadRecord(a.ctx, a.f.store, r.Holder.Key); err != nil {
76 a.t.Fatalf("the fixture's record does not read: %v", err)
77 }
78 a.records = append(a.records, r)
79}
80
81// publish writes the collecting manifest over the records, each box's
82// signed report carrying its seed evidence (a missing entry is a report
83// with none), and runs activation's Publish under the manifest's etag.
84func (a *activation) publish(evidence map[string]*CensusSeeds) error {
85 a.t.Helper()
86 p := BackfillPlan{V: 1, ID: "backfill", Phase: "collecting", DirectoryRevision: 1, CreatedAt: a.f.now, Hosts: map[string]BackfillHost{}}
87 for _, r := range a.records {
88 p.Hosts[r.Holder.Host] = BackfillHost{Key: r.Holder.Key, ETag: r.ETag, Acquisition: "acquisition", Region: "ie", Box: "box-a"}
89 }
90 raw, err := release.SignRootEnvelope(a.sg, p)
91 if err != nil {
92 a.t.Fatal(err)
93 }
94 tag, err := a.f.store.Put(a.ctx, BackfillKey, raw, "")
95 if err != nil {
96 a.t.Fatal(err)
97 }
98 for ref, box := range a.roster.Boxes {
99 report := CensusReport{Box: ref, Enrollment: box.Enrollment, BootID: "boot", Reservation: p.ID, DirectoryRevision: 1,
100 CapturedAt: a.f.now, Complete: true, Installations: []CensusInstallation{}, Seeds: evidence[ref]}
101 if err := PublishCensusReport(a.ctx, a.f.store, a.d, report, a.boxKeys[ref]); err != nil {
102 a.t.Fatalf("publish %s's census report: %v", ref, err)
103 }
104 }
105 b := &Backfill{Store: a.f.store, Directory: a.d}
106 return b.Publish(a.ctx, a.sg, tag)
107}
108
109// phase is the backfill manifest's phase now.
110func (a *activation) phase() string {
111 a.t.Helper()
112 b := &Backfill{Store: a.f.store, Directory: a.d}
113 p, _, err := b.readPlan(a.ctx)
114 if err != nil {
115 a.t.Fatal(err)
116 }
117 return p.Phase
118}
119
120func noSeeds() map[string]*CensusSeeds {
121 return map[string]*CensusSeeds{"box-a": {Journaled: []string{}, Gated: []string{}}, "box-b": {Journaled: []string{}, Gated: []string{}}}
122}
123
124func testSeed(kind, state string) *provision.SeedSource {
125 id, _ := seeds.NewID()
126 s := &provision.SeedSource{ID: id, Kind: kind, State: state, SHA256: strings.Repeat("a", 64), Bytes: 10, By: "t", At: time.Date(2026, 10, 5, 11, 0, 0, 0, time.UTC)}
127 if kind == seeds.KindImport {
128 s.Replaces, s.VerifyHash = "127.0.0.1:8401", strings.Repeat("b", 64)
129 } else {
130 s.From = "src.shop.example.com"
131 }
132 return s
133}
134
135// refused asserts err is activation's seed refusal over base, naming what.
136func refused(t *testing.T, err, base error, what string) {
137 t.Helper()
138 var se *SeedActivationError
139 if !errors.As(err, &se) || !errors.Is(err, base) || !strings.Contains(err.Error(), what) {
140 t.Fatalf("Publish = %v; want a seed refusal over %v naming %q", err, base, what)
141 }
142}
143
144// Merge review S2 (a): with every box reporting no seed state and no
145// record carrying an unfinished seed, activation publishes as before. A
146// finished seed does not hold it.
147func TestActivationPublishesWithNoUnfinishedSeed(t *testing.T) {
148 a := newActivation(t)
149 a.record("plain.shop.example.com", nil)
150 a.record("copy.shop.example.com", testSeed(seeds.KindReplica, seeds.StatePlaced))
151 a.record("moved.shop.example.com", testSeed(seeds.KindImport, seeds.StateCommitted))
152 if err := a.publish(noSeeds()); err != nil {
153 t.Fatalf("Publish: %v", err)
154 }
155 if got := a.phase(); got != "adopting" {
156 t.Fatalf("phase = %q, want adopting", got)
157 }
158}
159
160// Activation refuses over each unfinished seed a record can carry, under
161// the collecting freeze, and publishes nothing. That includes the two
162// states ruled in after the first fix: a --from seed at ready, which
163// nothing can place once claims are on, and an import at refused, which a
164// claim console cannot delete. Only a placed copy and a committed import
165// publish.
166func TestActivationRefusesAnUnfinishedSeedRecord(t *testing.T) {
167 for _, c := range []struct {
168 kind, state string
169 refuse bool
170 }{
171 {seeds.KindReplica, seeds.StateReserving, true},
172 {seeds.KindImport, seeds.StateAwaitingUpload, true},
173 {seeds.KindImport, seeds.StateUploaded, true},
174 {seeds.KindImport, seeds.StateReady, true},
175 {seeds.KindImport, seeds.StateSwapped, true},
176 {seeds.KindImport, seeds.StatePlaced, true},
177 {seeds.KindImport, seeds.StateAborted, true},
178 {seeds.KindReplica, seeds.StateAborted, true},
179 {seeds.KindImport, seeds.StateRolledBack, true},
180 {seeds.KindReplica, seeds.StateRolledBack, true},
181 {seeds.KindReplica, seeds.StateReady, true},
182 {seeds.KindImport, seeds.StateRefused, true},
183 {seeds.KindReplica, seeds.StateCommitted, true},
184 {"later", seeds.StatePlaced, true},
185 {"later", seeds.StateCommitted, true},
186 {seeds.KindReplica, seeds.StatePlaced, false},
187 {seeds.KindImport, seeds.StateCommitted, false},
188 } {
189 t.Run(c.kind+" "+c.state, func(t *testing.T) {
190 a := newActivation(t)
191 a.record("plain.shop.example.com", nil)
192 a.record("seeded.shop.example.com", testSeed(c.kind, c.state))
193 err := a.publish(noSeeds())
194 if !c.refuse {
195 if err != nil || a.phase() != "adopting" {
196 t.Fatalf("Publish = %v, phase %q; want published", err, a.phase())
197 }
198 return
199 }
200 refused(t, err, ErrPending, "seeded.shop.example.com has an unfinished seed ("+c.kind+", "+c.state+")")
201 if got := a.phase(); got != "collecting" {
202 t.Fatalf("phase = %q after a refusal, want collecting", got)
203 }
204 })
205 }
206}
207
208// Activation refuses when a box's signed report carries no seed state: it
209// fails closed, whatever the other boxes and the records say.
210func TestActivationRefusesWithoutABoxesSeedEvidence(t *testing.T) {
211 for name, evidence := range map[string]map[string]*CensusSeeds{
212 "no box says": {},
213 "one box says": {"box-a": {Journaled: []string{}, Gated: []string{}}},
214 "the other one": {"box-b": {Journaled: []string{}, Gated: []string{}}},
215 } {
216 t.Run(name, func(t *testing.T) {
217 a := newActivation(t)
218 a.record("plain.shop.example.com", nil)
219 err := a.publish(evidence)
220 refused(t, err, ErrIdentity, "reported no seed state")
221 if got := a.phase(); got != "collecting" {
222 t.Fatalf("phase = %q after a refusal, want collecting", got)
223 }
224 })
225 }
226 // A collector that cannot read its registry leaves the evidence out.
227 if seeds, err := CollectCensusSeeds(failingSeedState{}); err == nil || seeds != nil {
228 t.Fatalf("CollectCensusSeeds over a registry that cannot be read = %+v, %v", seeds, err)
229 }
230}
231
232type failingSeedState struct{}
233
234func (failingSeedState) List() ([]registry.Route, error) { return nil, errors.New("registry busy") }
235func (failingSeedState) ImportJournalHosts() ([]string, error) {
236 return nil, errors.New("registry busy")
237}
238
239// importOnBox is a box's registry holding one import, taken as far as
240// state: swapped (the gated row and a journal row), rolling-back (the gate
241// closed), or cleanup-pending (the original route back, the journal row
242// still there).
243func importOnBox(t *testing.T, host, state string) *registry.Registry {
244 t.Helper()
245 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
246 if err != nil {
247 t.Fatal(err)
248 }
249 t.Cleanup(func() { reg.Close() })
250 if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "127.0.0.1:8401", Status: "ready", App: "shop", Account: "bab", CreatedAt: 1700000000}); err != nil {
251 t.Fatal(err)
252 }
253 original, err := reg.Get(host)
254 if err != nil || original == nil {
255 t.Fatal(original, err)
256 }
257 id, _ := seeds.NewID()
258 next := registry.Route{Host: host, Kind: "instance", Socket: "/run/carlos/instances/x/app.sock", DBPath: "/data/instances/x/" + host + ".db",
259 Status: "ready", App: "shop", Account: "bab", Hibernate: true, Provisioned: true, ImportGate: strings.Repeat("b", 64),
260 SeedSource: seeds.RowSeed{ID: id, Kind: seeds.KindImport, State: seeds.StateSwapped}.Encode(), CreatedAt: 1700000001}
261 if err := reg.SwapForImport(registry.ImportJournal{Host: host, SeedID: id, Original: *original, RecordKey: provision.RecordKey("bab", "shop", host), At: 1,
262 EnvBefore: registry.EnvBeforeAbsent}, next); err != nil {
263 t.Fatal(err)
264 }
265 if state == registry.JournalSwapped {
266 return reg
267 }
268 if err := reg.CloseImportGate(host, id, 7); err != nil {
269 t.Fatal(err)
270 }
271 if state == registry.JournalRollingBack {
272 return reg
273 }
274 if err := reg.RestoreImportRoute(host, id); err != nil {
275 t.Fatal(err)
276 }
277 return reg
278}
279
280// The ruling's four states, with the box's evidence read from a real
281// registry by the collector's own function. swapped and placed are an
282// import the record still shows; rolling-back and cleanup-pending are
283// shown here with the record already gone, where only the box's signed
284// evidence can say an import is still in flight.
285func TestActivationRefusesAnImportInFlightOnABox(t *testing.T) {
286 const host = "old.shop.example.com"
287 for _, c := range []struct {
288 name string
289 record string // the record's seed state, "" for no record
290 journal string
291 wantJournaled bool
292 wantGated bool
293 }{
294 {"swapped", seeds.StateSwapped, registry.JournalSwapped, true, true},
295 {"placed", seeds.StatePlaced, registry.JournalSwapped, true, true},
296 {"rolling-back", "", registry.JournalRollingBack, true, true},
297 {"cleanup-pending", "", registry.JournalCleanupPending, true, false},
298 } {
299 t.Run(c.name, func(t *testing.T) {
300 a := newActivation(t)
301 a.record("plain.shop.example.com", nil)
302 if c.record != "" {
303 a.record(host, testSeed(seeds.KindImport, c.record))
304 }
305 evidence := noSeeds()
306 got, err := CollectCensusSeeds(importOnBox(t, host, c.journal))
307 if err != nil {
308 t.Fatal(err)
309 }
310 if (len(got.Journaled) == 1 && got.Journaled[0] == host) != c.wantJournaled || (len(got.Gated) == 1 && got.Gated[0] == host) != c.wantGated {
311 t.Fatalf("the box's evidence = %+v; want journaled %v, gated %v", got, c.wantJournaled, c.wantGated)
312 }
313 evidence["box-b"] = got
314 refused(t, a.publish(evidence), ErrPending, "box box-b still holds an import journal row for "+host)
315 if got := a.phase(); got != "collecting" {
316 t.Fatalf("phase = %q after a refusal, want collecting", got)
317 }
318 })
319 }
320 // A gate with no journal row (a committed import whose box has not yet
321 // cleared its gate cannot be told from one that never will) refuses too.
322 a := newActivation(t)
323 a.record("plain.shop.example.com", nil)
324 evidence := noSeeds()
325 evidence["box-a"] = &CensusSeeds{Journaled: []string{}, Gated: []string{host}}
326 refused(t, a.publish(evidence), ErrPending, "box box-a still gates "+host+" for an import")
327}
328
329// A box with no import reports two empty lists, which is not "did not
330// say": the collector's answer for a clean registry lets activation go on.
331func TestCollectCensusSeedsOnACleanBox(t *testing.T) {
332 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
333 if err != nil {
334 t.Fatal(err)
335 }
336 defer reg.Close()
337 if err := reg.Upsert(registry.Route{Host: "plain.shop.example.com", Kind: "instance", Socket: "/run/x.sock", App: "shop", Account: "bab", Hibernate: true}); err != nil {
338 t.Fatal(err)
339 }
340 got, err := CollectCensusSeeds(reg)
341 if err != nil || got == nil || got.Journaled == nil || got.Gated == nil || len(got.Journaled)+len(got.Gated) != 0 {
342 t.Fatalf("CollectCensusSeeds = %+v, %v; want two empty lists", got, err)
343 }
344 a := newActivation(t)
345 a.record("plain.shop.example.com", nil)
346 if err := a.publish(map[string]*CensusSeeds{"box-a": got, "box-b": got}); err != nil {
347 t.Fatalf("Publish with the collector's clean evidence: %v", err)
348 }
349}
350