carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
6 "context"
7 "database/sql"
8 "encoding/json"
9 "errors"
10 "net"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "testing"
15
16 "github.com/carlosframework/platform/internal/hostbox"
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21)
22
23// rootPeer is the fixture's live requester: the agent's process tuple.
24// ServeHTTP authenticates it with SO_PEERCRED and the controller registry in
25// production, which this test host does not have.
26var rootPeer = StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}
27
28// serve puts root's current handler on a Unix socket, as instancectl does,
29// with peer authentication fixed to rootPeer. A restart needs a new socket.
30func (w *assemblyWorld) serve() string {
31 w.t.Helper()
32 root := w.root
33 root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return rootPeer, nil }
34 return startRetirementRecoveryTransport(w.t, root)
35}
36
37// recoveryAgent is the agent's cross-box handover as claimconstruction.go
38// builds it in agent mode: root's recovery rebind, inventory and fence view
39// all through root's socket, the production identity, the box's registry.
40func (w *assemblyWorld) recoveryAgent(socket string, sent *recoveryRebindRequest) claimCrossBoxHandover {
41 w.t.Helper()
42 _, inventory := hostbox.RemoteJournals(rootJournalClient(socket))
43 guard := hostguard.Guard{DataDir: w.r.DataDir, LockDir: w.locks, Claims: hostguard.StoreReader{Store: w.store}, FenceView: rootFenceViewClient(rootJournalClient(socket))}
44 client := recoveryRebindClient(socket)
45 return claimCrossBoxHandover{RecoveryRebind: func(ctx context.Context, q recoveryRebindRequest) error {
46 if sent != nil {
47 *sent = q
48 }
49 return client(ctx, q)
50 }, Guard: guard, Evidence: w.store, Box: "box-1", Inventory: inventory, Identity: w.owners.base.Identity, Registry: w.reg}
51}
52
53// recoveryAdmission moves host's claim into a recovery this box executes
54// (a restoring op at probing whose source stamp is the installed one) by a
55// TEST-ONLY claim transition, then admits a cold writer through the real
56// hostclaim.AdmitWriter. It returns the claim before and after admission.
57func (w *assemblyWorld) recoveryAdmission(host string) (prior, admitted hostclaim.Observed) {
58 w.t.Helper()
59 ctx := w.t.Context()
60 o := w.claim(host)
61 stamp := o.Claim.Holder.Stamp
62 prior, err := hostclaim.Update(ctx, w.store, o, func(c *hostclaim.Claim) error {
63 c.Op = plan10J2Op(hostclaim.KindRestoring, hostclaim.PhaseProbing)
64 c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: strconv.FormatUint(stamp, 10)}
65 return nil
66 })
67 if err != nil {
68 w.t.Fatal(err)
69 }
70 h := prior.Claim.Holder
71 admitted, err = hostclaim.Update(ctx, w.store, prior, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: prior.Claim.Op.ID, ObservedPhase: prior.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "box-1", Mode: "cold", NewGeneration: "recovered", Region: "ie", PredecessorRegion: "ie"}))
72 if err != nil {
73 w.t.Fatal(err)
74 }
75 if admitted.Claim.Holder.Stamp <= stamp {
76 w.t.Fatal("admission did not move the stamp")
77 }
78 return prior, admitted
79}
80
81// restampInventory rewrites ONLY the stamp of host's installed inventory in
82// root's journal database, leaving its revision and every other field: the
83// one fact the predecessor check reads.
84func (w *assemblyWorld) restampInventory(host string, captured hostguard.Captured, stamp string) {
85 w.t.Helper()
86 db, err := sql.Open("sqlite", w.journal)
87 if err != nil {
88 w.t.Fatal(err)
89 }
90 defer db.Close()
91 var body []byte
92 if err = db.QueryRow("SELECT body FROM stop_inventory WHERE host=? AND incarnation=? AND stamp=?", host, captured.Incarnation, captured.Stamp).Scan(&body); err != nil {
93 w.t.Fatal(err)
94 }
95 var in hostbox.StopInventory
96 if err = json.Unmarshal(body, &in); err != nil {
97 w.t.Fatal(err)
98 }
99 in.DirectoryRevision = stamp
100 if body, err = json.Marshal(in); err != nil {
101 w.t.Fatal(err)
102 }
103 if _, err = db.Exec("UPDATE stop_inventory SET body=? WHERE host=? AND incarnation=? AND stamp=?", body, host, captured.Incarnation, captured.Stamp); err != nil {
104 w.t.Fatal(err)
105 }
106}
107
108func (w *assemblyWorld) view(host string, c hostguard.Captured) hostguard.FenceView {
109 w.t.Helper()
110 v, err := w.assembly.Guard.Root.View(w.t.Context(), host, c)
111 if err != nil {
112 w.t.Fatal(err)
113 }
114 return v
115}
116
117// successorInventory is root's inventory of admitted's installation, or
118// revision 0 when there is none.
119func (w *assemblyWorld) successorInventory(admitted hostclaim.Observed) (hostbox.StopInventory, uint64) {
120 w.t.Helper()
121 h := admitted.Claim.Holder
122 in, revision, err := w.assembly.Journals.Inventory.Snapshot(w.t.Context(), admitted.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp})
123 if err != nil {
124 w.t.Fatal(err)
125 }
126 return in, revision
127}
128
129// N4 (roster binding task 6, R3): a recovery rebind through the real
130// producer, installRecoveryRemote, and root's serveRecoveryRebind over the
131// Unix socket, with real hold churn between the predecessor's install and
132// the recovery and a hold held through it. The rebound inventory carries the
133// binding. Then the same, interrupted after root's intent is durable and
134// finished by a restarted root's RecoverPending (the startup replay of the
135// pending rebind intent), then the agent's retry. Then only the predecessor
136// inventory's stamp is changed: the rebind is refused at the predecessor
137// check, after the successor check has passed.
138func TestRemoteRecoveryRebindsSameBinding(t *testing.T) {
139 ctx := t.Context()
140 w := newAssemblyWorld(t)
141 w.mustStart()
142 b0 := w.fresh()
143 agent := w.agent(b0)
144 recA, routeA := w.newHost("a")
145 recB, routeB := w.newHost("b")
146 recC, routeC := w.newHost("c")
147 for _, h := range []struct {
148 rec provision.Record
149 route registry.Route
150 }{{recA, routeA}, {recB, routeB}, {recC, routeC}} {
151 if err := agent.install(h.rec, h.route); err != nil {
152 t.Fatal(err)
153 }
154 }
155 w.roster.holdChurn()
156 w.roster.holdChurn()
157 held := w.roster.reserveHold()
158 if actor := w.fresh(); actor.Revision == b0.Revision || actor.Binding != b0.Binding {
159 t.Fatalf("hold churn: revision %d -> %d, binding moved %v", b0.Revision, actor.Revision, actor.Binding != b0.Binding)
160 }
161
162 // Success through the producer and root's handler.
163 socket := w.serve()
164 prior, admitted := w.recoveryAdmission(recA.Host)
165 var sent recoveryRebindRequest
166 if err := w.recoveryAgent(socket, &sent).installRecoveryRemote(ctx, prior, admitted); err != nil {
167 t.Fatal("recovery rebind after hold churn:", err)
168 }
169 if sent.Next.DirectoryRevision != b0.Binding {
170 t.Fatalf("producer sent successor stamp %q, want the binding", sent.Next.DirectoryRevision)
171 }
172 rebound, revision := w.successorInventory(admitted)
173 if revision != 1 || rebound.DirectoryRevision != b0.Binding || rebound.InstallationStamp != admitted.Claim.Holder.Stamp {
174 t.Fatalf("rebound inventory: revision %d stamp %q installation %d", revision, rebound.DirectoryRevision, rebound.InstallationStamp)
175 }
176 sh := admitted.Claim.Holder
177 if v := w.view(recA.Host, hostguard.Captured{Incarnation: sh.Incarnation, Stamp: sh.Stamp}); v.Installation.Stamp != sh.Stamp {
178 t.Fatalf("root installation not rebound: %+v", v.Installation)
179 }
180 if row, err := w.reg.Get(recA.Host); err != nil || row == nil || row.ClaimStamp != sh.Stamp {
181 t.Fatalf("rows not rebound: %+v %v", row, err)
182 }
183 w.roster.releaseHold(held)
184
185 // Startup replay: root dies after its rebind intent is durable (the
186 // intent write succeeds, the commit write fails), restarts, and its
187 // RecoverPending finishes the intent under the unchanged binding.
188 prior, admitted = w.recoveryAdmission(recB.Host)
189 *w.interrupt = 1
190 err := w.recoveryAgent(socket, nil).installRecoveryRemote(ctx, prior, admitted)
191 *w.interrupt = -1
192 if err == nil || w.pending() != 1 {
193 t.Fatalf("rebind was not interrupted after its intent: %v, pending %d", err, w.pending())
194 }
195 w.roster.holdChurn()
196 if err := w.restart(); err != nil {
197 t.Fatal(err)
198 }
199 if err := w.root.RecoverPending(ctx); err != nil {
200 t.Fatal("restart with a pending same-binding rebind:", err)
201 }
202 if w.pending() != 0 {
203 t.Fatal("startup did not finish the pending rebind")
204 }
205 if in, revision := w.successorInventory(admitted); revision != 1 || in.DirectoryRevision != b0.Binding {
206 t.Fatalf("replayed rebind: revision %d stamp %q", revision, in.DirectoryRevision)
207 }
208 socket = w.serve()
209 if err := w.recoveryAgent(socket, nil).installRecoveryRemote(ctx, prior, admitted); err != nil {
210 t.Fatal("agent retry after the startup replay:", err)
211 }
212 if row, err := w.reg.Get(recB.Host); err != nil || row == nil || row.ClaimStamp != admitted.Claim.Holder.Stamp {
213 t.Fatalf("rows not rebound after the replay: %+v %v", row, err)
214 }
215
216 // Only the predecessor's stamp changes: refused at the predecessor check.
217 ch := w.claim(recC.Host).Claim.Holder
218 source := hostguard.Captured{Incarnation: ch.Incarnation, Stamp: ch.Stamp}
219 w.restampInventory(recC.Host, source, strings.Repeat("d", 64))
220 before := w.view(recC.Host, source)
221 prior, admitted = w.recoveryAdmission(recC.Host)
222 if err := w.recoveryAgent(socket, &sent).installRecoveryRemote(ctx, prior, admitted); err == nil {
223 t.Fatal("a predecessor stamped under another binding was rebound")
224 }
225 if sent.Next.DirectoryRevision != w.fresh().Binding {
226 t.Fatal("the successor stamp was not today's binding")
227 }
228 if _, revision := w.successorInventory(admitted); revision != 0 || w.pending() != 0 {
229 t.Fatalf("refusal left successor state: revision %d pending %d", revision, w.pending())
230 }
231 if after := w.view(recC.Host, source); after.Installation != before.Installation {
232 t.Fatalf("refusal changed root: %+v", after.Installation)
233 }
234 // The refusal is the predecessor check's (ErrTypedInventory), not the
235 // successor check's (ErrChanged).
236 if err := w.root.rebindRecoveryInventory(ctx, sent); !errors.Is(err, hostbox.ErrTypedInventory) {
237 t.Fatalf("refused at %v, want the predecessor check", err)
238 }
239}
240
241// L6 (roster binding R3): recovery never adopts a predecessor stamped under
242// another binding. Rebind (remote) and handover recovery (local) refuse a
243// predecessor installed at B0 once readmission makes the binding B1, and a
244// numeral-stamped predecessor; the local path still adopts a predecessor at
245// today's binding after hold churn. A numeral predecessor's receipt (no
246// binding field) fails verifyRetirementReceipt, so the operation cold
247// handover cannot park it. (The cold handover that parks a B0 predecessor
248// with a matching receipt and starts a successor at B1 is N5.)
249func TestClaimBindingRecoveryRefusesPredecessor(t *testing.T) {
250 t.Run("rebind", func(t *testing.T) {
251 ctx := t.Context()
252 w := newAssemblyWorld(t)
253 w.mustStart()
254 b0 := w.fresh()
255 agent := w.agent(b0)
256 recA, routeA := w.newHost("a")
257 recN, routeN := w.newHost("n")
258 for _, h := range []struct {
259 rec provision.Record
260 route registry.Route
261 }{{recA, routeA}, {recN, routeN}} {
262 if err := agent.install(h.rec, h.route); err != nil {
263 t.Fatal(err)
264 }
265 }
266 nh := w.claim(recN.Host).Claim.Holder
267 w.restampInventory(recN.Host, hostguard.Captured{Incarnation: nh.Incarnation, Stamp: nh.Stamp}, strconv.FormatUint(b0.Revision, 10))
268 w.roster.quarantine("loss_1")
269 w.roster.readmit()
270 b1 := w.fresh()
271 socket := w.serve()
272 for _, host := range []string{recA.Host, recN.Host} {
273 var sent recoveryRebindRequest
274 prior, admitted := w.recoveryAdmission(host)
275 if err := w.recoveryAgent(socket, &sent).installRecoveryRemote(ctx, prior, admitted); err == nil {
276 t.Fatalf("%s: a stale predecessor was rebound", host)
277 }
278 if sent.Next.DirectoryRevision != b1.Binding {
279 t.Fatalf("%s: successor stamp %q, want B1", host, sent.Next.DirectoryRevision)
280 }
281 if err := w.root.rebindRecoveryInventory(ctx, sent); !errors.Is(err, hostbox.ErrTypedInventory) {
282 t.Fatalf("%s: refused at %v, want the predecessor check", host, err)
283 }
284 if _, revision := w.successorInventory(admitted); revision != 0 {
285 t.Fatalf("%s: refusal wrote a successor inventory", host)
286 }
287 }
288 })
289
290 t.Run("handover recovery", func(t *testing.T) {
291 for _, stale := range []string{"none", "readmission", "numeral"} {
292 t.Run(stale, func(t *testing.T) {
293 ctx := t.Context()
294 roster := newBindingRosterFixture(t, "box-1", "ie")
295 identity := roster.identity()
296 b0, err := identity.Verify(ctx)
297 if err != nil {
298 t.Fatal(err)
299 }
300 data := t.TempDir()
301 inventory, err := openLegacyInventory(t, filepath.Join(t.TempDir(), "inventory.db"))
302 if err != nil {
303 t.Fatal(err)
304 }
305 t.Cleanup(func() { inventory.Close() })
306 reg := restartTestRegistry(t)
307 host := "shop.bab.oncarlos.com"
308 prior := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 4}, Op: hostclaim.Op{Journal: map[string]string{hostclaim.JournalSourceStamp: "4"}}}}
309 admitted := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 9}}}
310 route := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(data, host), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc-1", ClaimStamp: 4, ClaimOwner: "old-owner", ClaimRowID: "old-row"}}
311 if err := reg.Upsert(route); err != nil {
312 t.Fatal(err)
313 }
314 stamp := b0.Binding
315 if stale == "numeral" {
316 stamp = strconv.FormatUint(b0.Revision, 10)
317 }
318 if err := inventory.WriteStopInventory(ctx, hostbox.StopInventory{Complete: true, Host: host, Incarnation: "inc-1", InstallationStamp: 4, Box: "box-1", Enrollment: b0.Enrollment, DirectoryRevision: stamp, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: host, RowIdentity: "old-row", Ownership: "old-owner", Materialization: "materialized"}}}); err != nil {
319 t.Fatal(err)
320 }
321 switch stale {
322 case "none":
323 roster.holdChurn()
324 case "readmission":
325 roster.quarantine("loss_1")
326 roster.readmit()
327 }
328 h := claimCrossBoxHandover{Guard: hostguard.Guard{DataDir: data}, Box: "box-1", Inventory: inventory, Identity: identity, Registry: reg}
329 err = h.installRecoveryInventory(ctx, prior, admitted)
330 _, readErr := inventory.ReadInstallation(ctx, host, "inc-1", 9)
331 if stale == "none" {
332 if err != nil {
333 t.Fatal("handover recovery at today's binding after hold churn:", err)
334 }
335 successor, e := inventory.ReadInstallation(ctx, host, "inc-1", 9)
336 if e != nil || successor.DirectoryRevision != b0.Binding {
337 t.Fatalf("successor inventory %q %v", successor.DirectoryRevision, e)
338 }
339 return
340 }
341 if !errors.Is(err, hostbox.ErrTypedInventory) || !errors.Is(readErr, sql.ErrNoRows) {
342 t.Fatalf("stale predecessor: %v, successor read %v", err, readErr)
343 }
344 if row, e := reg.Get(host); e != nil || row.ClaimOwnership != route.ClaimOwnership {
345 t.Fatalf("refusal rebound rows: %+v %v", row, e)
346 }
347 })
348 }
349 })
350
351 t.Run("numeral receipt", func(t *testing.T) {
352 target := hostguard.FenceTarget{FenceID: "fence-1", Region: "ie", Host: "shop.bab.oncarlos.com", Incarnation: "inc-1", Stamp: 4, Session: 5, Op: "purge-1", Phase: "stopping", Box: "box-1", Enrollment: strings.Repeat("a", 32), BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "etag", DirectoryRevision: "7", RosterRevision: 7}
353 pre := StopReceipt{Kind: "stopped", ID: "receipt-1", FenceID: target.FenceID, Region: target.Region, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, OperationID: target.Op, Phase: target.Phase, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, ClaimETag: target.ClaimETag, DirectoryRevision: "7"}
354 if err := verifyRetirementReceipt(t.Context(), hostguard.FenceReceipt{Target: target, Bytes: mustMarshalStopReceipt(pre), EvidenceAt: pre.EvidenceAt}); !errors.Is(err, ErrClaimStopReceipt) {
355 t.Fatalf("a receipt without a binding proved a numeral predecessor: %v", err)
356 }
357 // Control: the binding field is the only difference.
358 carried := pre
359 carried.Binding = target.DirectoryRevision
360 if err := verifyRetirementReceipt(t.Context(), hostguard.FenceReceipt{Target: target, Bytes: mustMarshalStopReceipt(carried), EvidenceAt: carried.EvidenceAt}); err != nil {
361 t.Fatalf("control receipt refused: %v", err)
362 }
363 })
364}
365