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 unix
2
3package host
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "os"
12 "path/filepath"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostbox"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 execlease "github.com/carlosframework/platform/internal/hostexec"
20 "github.com/carlosframework/platform/internal/hostguard"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24// guardClaims is the guard's claim reader, set by the test.
25type guardClaims struct {
26 mu sync.Mutex
27 obs hostclaim.Observed
28}
29
30func (g *guardClaims) set(o hostclaim.Observed) { g.mu.Lock(); g.obs = o; g.mu.Unlock() }
31func (g *guardClaims) Read(context.Context, string) (hostclaim.Observed, error) {
32 g.mu.Lock()
33 defer g.mu.Unlock()
34 return g.obs, nil
35}
36
37// failFirstClaimRead fails the first Get of the claim key only: C10's
38// re-read (Astra round 4).
39type failFirstClaimRead struct {
40 release.Store
41 key string
42 failed bool
43 mu sync.Mutex
44}
45
46var errClaimReadDown = errors.New("control store unreachable")
47
48func (s *failFirstClaimRead) Get(ctx context.Context, key string) ([]byte, string, error) {
49 s.mu.Lock()
50 first := key == s.key && !s.failed
51 if first {
52 s.failed = true
53 }
54 s.mu.Unlock()
55 if first {
56 return nil, "", errClaimReadDown
57 }
58 return s.Store.Get(ctx, key)
59}
60
61// Astra round 4 (rev 4.2, ruling 70): after a restart between the serving
62// CAS and the participation discharge, a failure of only C10's claim
63// re-read never falls through to ordinary cleanup. Cleanup fails with that
64// error, nothing is stopped, the completed probe's fence is not replaced;
65// on retry the participation is discharged by the binding and the probe's
66// receipt, and the first serving wake's cold handover (N6's prior, the
67// probe's receipt) is accepted by the guard.
68func TestRelocationRestartDischargeSurvivesAFailedClaimRead(t *testing.T) {
69 ctx := t.Context()
70 const host, box = "shop.example", "dst"
71 created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
72 inc, err := hostclaim.Incarnation("acct", "app", host, created)
73 if err != nil {
74 t.Fatal(err)
75 }
76 holder := func(session, stamp uint64, gen string) *hostclaim.Holder {
77 return &hostclaim.Holder{Family: "instance", Key: "apps/acct/app/instances/" + host + ".json", Account: "acct", App: "app", Incarnation: inc, CreatedAt: created,
78 Location: "instances/" + host, Stamp: stamp, Session: session, Generation: gen, WriterBox: box,
79 Admissions: []hostclaim.AdmissionRecord{{Session: 12, Box: box, Generation: "P1", Mode: "cold", Region: "ie"}}}
80 }
81 probing := hostclaim.Observed{Host: host, ETag: "e1", Claim: &hostclaim.Claim{V: 2, Gen: 30, Host: host, Holder: holder(12, 9, "P1"),
82 Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Exec: box, Participants: []string{box}}}}
83 reader := &guardClaims{obs: probing}
84 dataDir := t.TempDir()
85 if err := os.Mkdir(filepath.Join(dataDir, "hostguard"), 0o770); err != nil {
86 t.Fatal(err)
87 }
88 guard := hostguard.Guard{DataDir: dataDir, LockDir: t.TempDir(), Claims: reader}
89 if err := guard.Activate(ctx, host, box, nil); err != nil {
90 t.Fatal(err)
91 }
92 // The probe's writer-session stop, completed with root's receipt
93 // (ruling 49), while the op was still relocating/probing.
94 prior := hostguard.FenceTarget{FenceID: "fence-1", Region: "ie", Host: host, Incarnation: inc, Stamp: 9, Session: 12, Op: "rel-1", Phase: hostclaim.PhaseRelocatingProbing,
95 ObligationID: hostguard.WriterStopObligation(12), Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", ClaimETag: "e1", DirectoryRevision: "1", RosterRevision: 1}
96 if _, err := guard.BeginFence(ctx, prior); err != nil {
97 t.Fatal(err)
98 }
99 at := time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC)
100 receipt := mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r1", Kind: StopReceiptStopped, Region: "ie", ClaimETag: "e1", ObligationID: prior.ObligationID, FenceID: "fence-1", Host: host, Incarnation: inc,
101 InstallationStamp: 9, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", DirectoryRevision: testReceiptNumeral(prior), Binding: "1", EvidenceAt: at})
102 if err := guard.CompleteFence(ctx, prior, receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
103 t.Fatal(err)
104 }
105 sum := sha256.Sum256(receipt)
106 done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: box, Session: 12, Receipt: "fence-1", ReceiptSHA256: hex.EncodeToString(sum[:])}, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"})
107 serving := &hostclaim.Claim{V: 2, Gen: 31, Host: host, Holder: holder(12, 9, "P1"),
108 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: box, Participants: []string{box}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}}
109 raw, err := json.Marshal(serving)
110 if err != nil {
111 t.Fatal(err)
112 }
113 key, _ := hostclaim.Key(host)
114 dir := release.NewDirStore(t.TempDir())
115 if _, err := dir.Put(ctx, key, raw, ""); err != nil {
116 t.Fatal(err)
117 }
118 if obs, err := hostclaim.Read(ctx, dir, host); err != nil || obs.Claim.Op.Kind != hostclaim.KindServing {
119 t.Fatalf("the serving claim does not read back: %v", err)
120 }
121 reader.set(hostclaim.Observed{Host: host, ETag: "e2", Claim: serving})
122 store := &failFirstClaimRead{Store: dir, key: key}
123 stops := 0
124 a := &ClaimAssembly{Claims: store, Guard: guard, Wake: &ClaimWakeAssembly{}, Launches: &hostbox.LaunchJournal{},
125 Reconcile: &hostbox.ReconcileAdapter{Census: &hostbox.EmptyInstallationCensus{}},
126 collectStop: func(context.Context, StopRequest, StopDrainer) (StopResult, error) {
127 stops++
128 return StopResult{}, errors.New("a serving-shaped stop")
129 }}
130 p := &productionClaimLocal{legacyParticipation: true, assembly: a, slots: map[string]*claimParticipation{}}
131 // The restart: clearUntracked hands cleanup the current serving
132 // observation and the persisted .hostexec binding.
133 binding := execlease.Binding{Box: box, Enrollment: "enr", Host: host, Incarnation: inc, Op: "rel-1", BootID: "boot-0"}
134 current := hostclaim.Observed{Host: host, ETag: "e2", Claim: serving}
135
136 if err := p.cleanup(ctx, current, binding); !errors.Is(err, errClaimReadDown) {
137 t.Fatalf("a failed C10 read fell through: %v", err)
138 }
139 if stops != 0 {
140 t.Fatal("ordinary cleanup asked a stop while the claim read was failing")
141 }
142 if got, err := guard.CompletedWriterStops(ctx, host, hostguard.Captured{Incarnation: inc, Stamp: 9}, "rel-1", box); err != nil || len(got) != 1 || got[0].Target != prior {
143 t.Fatalf("the probe's receipt changed: %+v %v", got, err)
144 }
145
146 // The retry: storage recovered.
147 if err := p.cleanup(ctx, current, binding); err != nil {
148 t.Fatalf("the retry did not discharge: %v", err)
149 }
150 if stops != 0 {
151 t.Fatal("the discharge asked a stop")
152 }
153 // The first serving wake: N6's Before verifies the receipt, stopping
154 // nothing; after its cold admission (session and stamp 40), the
155 // guard's handover barrier over the probe's receipt is accepted,
156 // which a replacement fence would have refused (ErrFenceActive).
157 h := relocationCompletedHandover{Guard: guard, Box: box}
158 if err := h.Before(ctx, current); err != nil {
159 t.Fatalf("N6 before admission: %v", err)
160 }
161 r, err := h.receipt(ctx, current)
162 if err != nil || r.Target != prior {
163 t.Fatalf("N6's prior is not the probe's receipt: %+v %v", r.Target, err)
164 }
165 admitted := *serving
166 admitted.Holder = holder(40, 40, "g40")
167 admitted.Holder.Admissions = append(admitted.Holder.Admissions, hostclaim.AdmissionRecord{Session: 40, Box: box, Generation: "g40", Mode: "cold", Region: "ie"})
168 reader.set(hostclaim.Observed{Host: host, ETag: "e3", Claim: &admitted})
169 barrier := prior
170 barrier.FenceID, barrier.Stamp, barrier.Session, barrier.Op, barrier.Phase, barrier.ObligationID, barrier.ClaimETag = "handover-1", 40, 40, "", "", "", "e3"
171 if err := guard.BeginColdHandover(ctx, prior, barrier, func(*hostclaim.Claim) error { return nil }); err != nil {
172 t.Fatalf("the serving handover from the probe's receipt: %v", err)
173 }
174}
175