carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostclaim"
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/instancectl"
18 "github.com/carlosframework/platform/internal/registry"
19)
20
21func retirementRecoveryRow(t *testing.T, f *recoveryFixture) registry.Route {
22 t.Helper()
23 o := f.read()
24 var err error
25 o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.BeginDelete("delete-1", "console", `"record-etag"`, f.now, nil, nil, ""))
26 if err != nil {
27 t.Fatal(err)
28 }
29 o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.ToParking("delete-1", nil, nil, f.newID))
30 if err != nil {
31 t.Fatal(err)
32 }
33
34 h := o.Claim.Holder
35 return registry.Route{Host: o.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: h.Incarnation, ClaimStamp: h.Stamp, ClaimOwner: "installation", ClaimRowID: "route-1"}}
36}
37
38func recordRetirementSuccessor(t *testing.T, f *recoveryFixture, admitted hostclaim.Observed, actor hostbox.Actor) (hostguard.FenceTarget, hostbox.LaunchReservation) {
39 t.Helper()
40 successor := recoveryTuple(admitted, claimStopLocal{Actor: actor, BootID: "boot-1", ControllerID: "controller-2"})
41 successor.Session++
42 successor.Stamp++
43 successor.DirectoryRevision = "8"
44 if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: 4202, ProcessStart: "start-2", Target: successor, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/retirement-successor", Device: 1, Inode: 99}}, NoState: true}); err != nil {
45 t.Fatal(err)
46 }
47 reservation := hostbox.LaunchReservation{ID: "successor:main", Role: "main", InvocationID: "successor-invocation", Inputs: "inputs", Target: successor, Group: hostbox.LaunchGroup{RelativePath: "carlos/retirement-successor/role", Device: 1, Inode: 100, InvocationID: "successor-invocation"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 101}}
48 if err := f.journal.Reserve(t.Context(), reservation); err != nil {
49 t.Fatal(err)
50 }
51 return successor, reservation
52}
53
54func retirementRecoveryReceipts(t *testing.T, f *recoveryFixture) []StopReceipt {
55 t.Helper()
56 keys, err := f.evidence.List(t.Context(), "control/hosts/"+recoveryHost+"/evidence/delete-1/box-1/")
57 if err != nil {
58 t.Fatal(err)
59 }
60 out := make([]StopReceipt, 0, len(keys))
61 for _, key := range keys {
62 if !strings.HasSuffix(key, ".json") {
63 continue
64 }
65 body, _, err := f.evidence.Get(t.Context(), key)
66 if err != nil {
67 t.Fatal(err)
68 }
69 var receipt StopReceipt
70 if err := json.Unmarshal(body, &receipt); err != nil {
71 t.Fatal(err)
72 }
73 out = append(out, receipt)
74 }
75 return out
76}
77
78func startRetirementRecoveryTransport(t *testing.T, handler ClaimRootHTTP) string {
79 t.Helper()
80 directory, err := os.MkdirTemp("", "retire-")
81 if err != nil {
82 t.Fatal(err)
83 }
84 t.Cleanup(func() { os.RemoveAll(directory) })
85 socket := filepath.Join(directory, "s")
86 listener, err := net.Listen("unix", socket)
87 if err != nil {
88 t.Fatal(err)
89 }
90 ctx, cancel := context.WithCancel(t.Context())
91 done := make(chan error, 1)
92 server := instancectl.Server{ClaimStop: handler, PeerOK: func(net.Conn) error { return nil }}
93 go func() { done <- server.Serve(ctx, listener) }()
94 t.Cleanup(func() {
95 cancel()
96 _ = listener.Close()
97 if err := <-done; err != nil {
98 t.Errorf("retirement transport: %v", err)
99 }
100 })
101 return socket
102}
103
104func TestClaimRetirementRecoversPersistedLaunchOwner(t *testing.T) {
105 t.Parallel()
106 f := newRecoveryFixture(t)
107 former := f.controller("boot-1", "controller-1", 4101, "start-1")
108 admitted := former.admit(t, f)
109 successor, reservation := recordRetirementSuccessor(t, f, admitted, former.local.Actor)
110 row := retirementRecoveryRow(t, f)
111
112 f.revision = 8
113 restarted := f.controller("boot-1", "controller-2", 4202, "start-2")
114 f.kernel.alive = true
115 if _, err := restarted.assembly.collectRetirementStop(t.Context(), row); !errors.Is(err, ErrClaimStopControllerAlive) {
116 t.Fatalf("live launch owner retired = %v", err)
117 }
118 if len(f.requests) != 1 {
119 t.Fatalf("stop requests before death proof = %+v", f.requests)
120 }
121 request := f.requests[0]
122 if !request.Recover || request.BootID != "boot-1" || request.ControllerID != "controller-1" || request.ExpectedDirectoryRevision != "7" {
123 t.Fatalf("retirement substituted the restarted controller tuple: %+v", request)
124 }
125 if got := retirementRecoveryReceipts(t, f); len(got) != 0 {
126 t.Fatalf("receipt before root death proof: %+v", got)
127 }
128
129 f.kernel.alive = false
130 target, err := restarted.assembly.collectRetirementStop(t.Context(), row)
131 if err != nil {
132 t.Fatal(err)
133 }
134 if target.BootID != "boot-1" || target.ControllerID != "controller-1" || target.DirectoryRevision != "7" || target.Session != admitted.Claim.Holder.Session {
135 t.Fatalf("retirement target = %+v", target)
136 }
137 if len(f.kernel.verified) != 0 || len(f.kernel.recovered) != 2 || f.kernel.recovered[0].FenceID != f.kernel.recovered[1].FenceID {
138 t.Fatalf("verified=%v recovered=%v", f.kernel.verified, f.kernel.recovered)
139 }
140 other, err := f.journal.Read(t.Context(), successor)
141 expected := reservation
142 expected.State = "reserved"
143 if err != nil || len(other.Reservations) != 1 || other.Reservations[0] != expected {
144 t.Fatalf("retirement changed successor: %+v %v", other, err)
145 }
146 if got := retirementRecoveryReceipts(t, f); len(got) != 1 || got[0].ControllerID != "controller-1" || got[0].DirectoryRevision != "7" || got[0].FenceID != target.FenceID {
147 t.Fatalf("receipts = %+v", got)
148 }
149
150 // A bookkeeping CAS changes the claim ETag but not the stop authority.
151 // Recollection must replay the immutable receipt and retain its old tuple.
152 current := f.read()
153 if _, err = hostclaim.Update(t.Context(), f.claims, current, func(*hostclaim.Claim) error { return nil }); err != nil {
154 t.Fatal(err)
155 }
156 recoveredBefore := len(f.kernel.recovered)
157 replayed, err := restarted.assembly.collectRetirementStop(t.Context(), row)
158 if err != nil {
159 t.Fatalf("receipt replay after bookkeeping CAS: %v", err)
160 }
161 if replayed != target || len(f.kernel.recovered) != recoveredBefore {
162 t.Fatalf("replay target=%+v recovered=%d, want %+v and %d", replayed, len(f.kernel.recovered), target, recoveredBefore)
163 }
164
165 // Retirement acknowledgement reads the claim from the evidence store in
166 // production. Mirror the fixture's claim there before exercising root HTTP.
167 claimKey, err := hostclaim.Key(recoveryHost)
168 if err != nil {
169 t.Fatal(err)
170 }
171 claimBody, _, err := f.claims.Get(t.Context(), claimKey)
172 if err != nil {
173 t.Fatal(err)
174 }
175 if _, err = f.evidence.Put(t.Context(), claimKey, claimBody, ""); err != nil {
176 t.Fatal(err)
177 }
178
179 base := t.TempDir()
180 inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db"))
181 if err != nil {
182 t.Fatal(err)
183 }
184 t.Cleanup(func() { inventory.Close() })
185 reg, err := registry.Open(filepath.Join(base, "registry.db"))
186 if err != nil {
187 t.Fatal(err)
188 }
189 t.Cleanup(func() { reg.Close() })
190 owner := row.ClaimOwnership
191 in := hostbox.StopInventory{Complete: true, Host: row.Host, Incarnation: owner.ClaimIncarnation, InstallationStamp: owner.ClaimStamp, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "7", Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: row.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}}
192 if err = inventory.WriteStopInventory(t.Context(), in); err != nil {
193 t.Fatal(err)
194 }
195 successorRow := row
196 successorRow.ClaimStamp++
197 successorRow.ClaimOwner = "successor"
198 successorRow.ClaimRowID = "route-2"
199 if err = reg.Upsert(row); err != nil {
200 t.Fatal(err)
201 }
202 coordinator := f.root(recoveryStopDrain{})
203 root := ClaimRootHTTP{
204 Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }},
205 Roots: map[string]string{"data": base},
206 Reconcile: hostbox.ReconcileAdapter{
207 Box: "box-1", Enrollment: "enrollment-1", Inventory: inventory, Registry: reg,
208 Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")},
209 },
210 }
211 socket := startRetirementRecoveryTransport(t, root)
212 unknown := target
213 unknown.ControllerID = "unknown-controller"
214 if err = RetireClaimOverSocket(t.Context(), socket, unknown); !errors.Is(err, ErrClaimStopReceipt) {
215 t.Fatalf("unknown old target = %v", err)
216 }
217 if got, e := reg.Get(row.Host); e != nil || got == nil || got.ClaimRowID != row.ClaimRowID {
218 t.Fatalf("unknown target changed row: %+v %v", got, e)
219 }
220 if err = RetireClaimOverSocket(t.Context(), socket, target); err != nil {
221 t.Fatalf("retire recovered target over HTTP: %v", err)
222 }
223 if got, e := reg.Get(row.Host); e != nil || got != nil {
224 t.Fatalf("retirement left original route: %+v %v", got, e)
225 }
226 if err = reg.Upsert(successorRow); err != nil {
227 t.Fatal(err)
228 }
229 if err = RetireClaimOverSocket(t.Context(), socket, target); err != nil {
230 t.Fatalf("replay recovered retirement over HTTP: %v", err)
231 }
232 if got, e := reg.Get(row.Host); e != nil || got == nil || got.ClaimRowID != successorRow.ClaimRowID {
233 t.Fatalf("recovered retirement changed successor: %+v %v", got, e)
234 }
235}
236
237func TestClaimRetirementRefusesMissingOrAmbiguousLaunchJournal(t *testing.T) {
238 t.Run("missing", func(t *testing.T) {
239 f := newRecoveryFixture(t)
240 f.recordPrelaunch = false
241 former := f.controller("boot-1", "controller-1", 4101, "start-1")
242 former.admit(t, f)
243 row := retirementRecoveryRow(t, f)
244 f.revision = 8
245 restarted := f.controller("boot-1", "controller-2", 4202, "start-2")
246 if _, err := restarted.assembly.collectRetirementStop(t.Context(), row); !errors.Is(err, hostbox.ErrChanged) {
247 t.Fatalf("missing launch journal = %v", err)
248 }
249 if len(f.requests) != 0 || len(f.kernel.verified) != 0 || len(f.kernel.recovered) != 0 {
250 t.Fatalf("missing journal caused stop effects: requests=%+v verified=%v recovered=%v", f.requests, f.kernel.verified, f.kernel.recovered)
251 }
252 if got := retirementRecoveryReceipts(t, f); len(got) != 0 {
253 t.Fatalf("missing journal produced evidence: %+v", got)
254 }
255 })
256
257 t.Run("ambiguous", func(t *testing.T) {
258 f := newRecoveryFixture(t)
259 former := f.controller("boot-1", "controller-1", 4101, "start-1")
260 admitted := former.admit(t, f)
261 second := recoveryTuple(admitted, claimStopLocal{Actor: former.local.Actor, BootID: "boot-1", ControllerID: "controller-other"})
262 if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: 4199, ProcessStart: "start-other", Target: second, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/ambiguous", Device: 1, Inode: 98}}, NoState: true}); err != nil {
263 t.Fatal(err)
264 }
265 row := retirementRecoveryRow(t, f)
266 f.revision = 8
267 restarted := f.controller("boot-1", "controller-2", 4202, "start-2")
268 if _, err := restarted.assembly.collectRetirementStop(t.Context(), row); !errors.Is(err, hostbox.ErrChanged) {
269 t.Fatalf("ambiguous launch journal = %v", err)
270 }
271 if len(f.requests) != 0 || len(f.kernel.verified) != 0 || len(f.kernel.recovered) != 0 {
272 t.Fatalf("ambiguous journal caused partial stop: requests=%+v verified=%v recovered=%v", f.requests, f.kernel.verified, f.kernel.recovered)
273 }
274 if got := retirementRecoveryReceipts(t, f); len(got) != 0 {
275 t.Fatalf("ambiguous journal produced evidence: %+v", got)
276 }
277 })
278}
279
280func TestClaimRetirementAfterServingStopSameController(t *testing.T) {
281 f := newRecoveryFixture(t)
282 controller := f.controller("boot-1", "controller-1", 4101, "start-1")
283 admitted := controller.admit(t, f)
284 if err := controller.wake.Stop(t.Context(), admitted); err != nil {
285 t.Fatal("serving stop", err)
286 }
287 row := retirementRecoveryRow(t, f)
288 target, err := controller.assembly.collectRetirementStop(t.Context(), row)
289 if err != nil {
290 t.Fatal("retirement after serving stop", err)
291 }
292 if target.Op != "delete-1" || target.Phase != "park" || target.ObligationID == "" || target.Session != admitted.Claim.Holder.Session {
293 t.Fatalf("retirement tuple: %+v", target)
294 }
295}
296
297func TestClaimRetirementCannotReplaceUnfinishedDrain(t *testing.T) {
298 target := hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "en", BootID: "boot", ControllerID: "controller"}
299 p, err := NewClaimProducers(target, "start", func(context.Context, hostguard.FenceTarget, string) error { return nil })
300 if err != nil {
301 t.Fatal(err)
302 }
303 finish := make(chan struct{})
304 defer func() {
305 select {
306 case <-finish:
307 default:
308 close(finish)
309 }
310 }()
311 if err = p.Launch(t.Context(), target, "writer", func(ctx context.Context) error { <-ctx.Done(); <-finish; return ctx.Err() }); err != nil {
312 t.Fatal(err)
313 }
314 req := StopRequest{Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: 1, WriterSession: 1, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID}
315 short, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
316 _, err = p.DrainClaim(short, req, "serving-fence")
317 cancel()
318 if !errors.Is(err, context.DeadlineExceeded) {
319 t.Fatal("unfinished drain", err)
320 }
321 short, cancel = context.WithTimeout(t.Context(), 20*time.Millisecond)
322 _, err = p.DrainClaim(short, req, "retirement-fence")
323 cancel()
324 if !errors.Is(err, ErrClaimStopIdentity) {
325 t.Fatal("replaced unfinished drain", err)
326 }
327 close(finish)
328 if _, err = p.DrainClaim(t.Context(), req, "serving-fence"); err != nil {
329 t.Fatal(err)
330 }
331 ack, err := p.DrainClaim(t.Context(), req, "retirement-fence")
332 if err != nil || ack.FenceID != "retirement-fence" {
333 t.Fatal("completed drain retirement", ack, err)
334 }
335 req.WriterSession++
336 if _, err = p.DrainClaim(t.Context(), req, "foreign-fence"); !errors.Is(err, ErrClaimStopIdentity) {
337 t.Fatal("foreign session rebound", err)
338 }
339 if err = p.Launch(t.Context(), target, "new-writer", func(context.Context) error { return nil }); err == nil {
340 t.Fatal("retirement reopened producer admission")
341 }
342}
343