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 "bytes"
5 "context"
6 "crypto"
7 "crypto/rand"
8 "crypto/rsa"
9 "crypto/sha256"
10 "crypto/x509"
11 "encoding/base64"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "net"
16 "net/http"
17 "os"
18 "path/filepath"
19 "strings"
20 "sync"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/boxid"
25 "github.com/carlosframework/platform/internal/hostbox"
26 "github.com/carlosframework/platform/internal/hostclaim"
27 "github.com/carlosframework/platform/internal/hostguard"
28 "github.com/carlosframework/platform/internal/provision"
29 "github.com/carlosframework/platform/internal/registry"
30)
31
32type handoverRecoveryRoster struct{ actor hostbox.Actor }
33
34func (r handoverRecoveryRoster) Actor(context.Context, string) (hostbox.Actor, error) {
35 return r.actor, nil
36}
37
38type handoverRecoveryEvidence []byte
39
40func (e handoverRecoveryEvidence) Load(context.Context, string) ([]byte, error) {
41 return e, nil
42}
43
44// handoverRecoveryKey is the RSA key every test identity signs its document
45// with. Generating a 2048-bit key costs tens of milliseconds and this helper
46// has dozens of callers, many in loops, so the process makes one. Each
47// provider still carries its own document, signature and certificate map, and
48// no test verifies one provider's evidence against another's certificates.
49var handoverRecoveryKey = sync.OnceValues(func() (*rsa.PrivateKey, error) {
50 return rsa.GenerateKey(rand.Reader, 2048)
51})
52
53func handoverRecoveryIdentity(t *testing.T, actor hostbox.Actor) hostbox.IdentityProvider {
54 t.Helper()
55 key, err := handoverRecoveryKey()
56 if err != nil {
57 t.Fatal(err)
58 }
59 actor.Bucket, actor.Namespace, actor.Eligible = "data", "platform", true
60 if actor.Binding == "" {
61 // Fixture shorthand: these fixtures stamp installed state with the
62 // actor's numeral, so the opaque binding defaults to that string. The
63 // production roster computes hostactors.BoxBinding; the binding
64 // witnesses (claim_binding_*_test.go) run on it.
65 actor.Binding = fmt.Sprint(actor.Revision)
66 }
67 actor.Cloud = hostbox.CloudIdentity{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}
68 doc, err := json.Marshal(map[string]string{"accountId": actor.Cloud.Account, "region": actor.Cloud.Region, "instanceId": actor.Cloud.InstanceID})
69 if err != nil {
70 t.Fatal(err)
71 }
72 sum := sha256.Sum256(doc)
73 signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
74 if err != nil {
75 t.Fatal(err)
76 }
77 raw, err := json.Marshal(boxid.Record{V: 1, Box: actor.Box, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(signature)})
78 if err != nil {
79 t.Fatal(err)
80 }
81 return hostbox.IdentityProvider{Account: actor.Cloud.Account, Box: actor.Box, Region: actor.Region, AWSRegion: actor.Cloud.Region, Bucket: actor.Bucket, Namespace: actor.Namespace, Roster: handoverRecoveryRoster{actor}, Evidence: handoverRecoveryEvidence(raw), Certs: map[string]*x509.Certificate{actor.Cloud.Region: {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
82}
83
84func postRecoveryHandover(t *testing.T, socket string, request coldHandoverRequest) int {
85 t.Helper()
86 body, err := json.Marshal(request)
87 if err != nil {
88 t.Fatal(err)
89 }
90 transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
91 return (&net.Dialer{}).DialContext(ctx, "unix", socket)
92 }}
93 defer transport.CloseIdleConnections()
94 req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://instance-control/claim/handover", bytes.NewReader(body))
95 if err != nil {
96 t.Fatal(err)
97 }
98 response, err := (&http.Client{Transport: transport}).Do(req)
99 if err != nil {
100 t.Fatal(err)
101 }
102 defer response.Body.Close()
103 return response.StatusCode
104}
105
106// The kernel and lease are fixtures. Recovery, persisted receipts, the Unix
107// root endpoint, cold handover, writer admission and next restore are real.
108func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) {
109 t.Parallel()
110 for _, boot := range []string{"boot-1", "boot-2"} {
111 t.Run(boot, func(t *testing.T) {
112 ctx := t.Context()
113 f := newRecoveryFixture(t)
114 // Production reads claims and publishes evidence in the same bucket.
115 f.evidence = f.claims
116 recordedBoot, parkRuntime := servingRecoveryFilesystem(t, f.guard.DataDir)
117 if boot == "boot-1" {
118 boot = recordedBoot
119 }
120 former := f.controller(recordedBoot, "controller-1", 4101, "start-1")
121 admitted := former.admit(t, f)
122 f.revision = 8
123 restarted := f.controller(boot, "controller-2", 4202, "start-2")
124 route := registry.Route{Host: recoveryHost, Kind: "instance", Account: admitted.Claim.Holder.Account, App: admitted.Claim.Holder.App, DBPath: provision.NestedDBPath(f.guard.DataDir, recoveryHost), Socket: provision.NestedSocket(recoveryHost), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: admitted.Claim.Holder.Incarnation, ClaimStamp: admitted.Claim.Holder.Stamp, ClaimOwner: "old-installation", ClaimRowID: "old-route"}}
125 f.kernel.alive = true
126 if err := restarted.runtime.Prerequisites(ctx, route, f.read(), recoveryPolicy); !errors.Is(err, ErrClaimStopControllerAlive) {
127 t.Fatalf("live predecessor accepted: %v", err)
128 }
129 f.kernel.alive = false
130 if err := restarted.runtime.Prerequisites(ctx, route, f.read(), recoveryPolicy); !errors.Is(err, hostbox.ErrChanged) {
131 t.Fatalf("restart recovery: %v", err)
132 }
133 if retry := f.retry(); retry.State != "open" || retry.AttemptsReserved != 1 || retry.Attempt != nil {
134 t.Fatalf("recovery refunded or stranded attempt: %+v", retry)
135 }
136 prior := f.read()
137 receipt, err := f.guard.CompletedColdPredecessor(ctx, prior.Host, hostguard.Captured{Incarnation: prior.Claim.Holder.Incarnation, Stamp: prior.Claim.Holder.Stamp}, prior.Claim.Holder.Session, prior.Claim.Op.ID, prior.Claim.Op.Phase)
138 if err != nil {
139 t.Fatal(err)
140 }
141 if receipt.Target.ControllerID != "controller-1" || receipt.Target.BootID != recordedBoot || receipt.Target.DirectoryRevision != "7" {
142 t.Fatalf("recovered tuple was rewritten: %+v", receipt.Target)
143 }
144 base := t.TempDir()
145 inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db"))
146 if err != nil {
147 t.Fatal(err)
148 }
149 t.Cleanup(func() { inventory.Close() })
150 reg, err := registry.Open(filepath.Join(base, "registry.db"))
151 if err != nil {
152 t.Fatal(err)
153 }
154 t.Cleanup(func() { reg.Close() })
155 if err = reg.Upsert(route); err != nil {
156 t.Fatal(err)
157 }
158 owner := route.ClaimOwnership
159 in := hostbox.StopInventory{Complete: true, Host: route.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: route.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}}
160 if err = os.MkdirAll(filepath.Dir(route.DBPath), 0700); err != nil {
161 t.Fatal(err)
162 }
163 rel, err := filepath.Rel(f.guard.DataDir, filepath.Dir(route.DBPath))
164 if err != nil {
165 t.Fatal(err)
166 }
167 roots := map[string]string{"data": f.guard.DataDir}
168 a, err := (hostbox.TypedParker{Roots: roots, ParkedRoots: roots, Incarnation: owner.ClaimIncarnation, Host: route.Host, Retirement: "fixture"}).CaptureArtifact("data", rel, owner.ClaimOwner, hostbox.ArtifactPath)
169 if err != nil {
170 t.Fatal(err)
171 }
172 in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner.ClaimOwner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type})
173 holder := prior.Claim.Holder
174 body, err := json.Marshal(provision.Record{Schema: 1, Host: prior.Host, Account: holder.Account, App: holder.App, CreatedAt: holder.CreatedAt, Channel: "edge"})
175 if err != nil {
176 t.Fatal(err)
177 }
178 if _, err = f.claims.Put(ctx, holder.Key, body, ""); err != nil {
179 t.Fatal(err)
180 }
181 if err = inventory.WriteStopInventory(ctx, in); err != nil {
182 t.Fatal(err)
183 }
184
185 rootGuard := f.guard
186 rootGuard.Root = hostguard.NewRootAuthorityWithInventory(&rollbackDisk{dir: t.TempDir()}, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) {
187 _, revision, err := inventory.Snapshot(ctx, host, c)
188 return revision, err
189 })
190 held := prior.Claim.Holder
191 if err := rootGuard.EstablishRoot(ctx, hostguard.RootInstallation{Host: prior.Host, Incarnation: held.Incarnation, Stamp: held.Stamp, Account: held.Account, App: held.App, Record: held.Key, Box: "box-1", Enrollment: "enrollment-1", Migration: 1, InventoryRevision: 1}, func(*hostclaim.Claim) error { return nil }); err != nil {
192 t.Fatal(err)
193 }
194 observedFence := prior
195 observedFence.ETag = receipt.Target.ClaimETag
196 rootGuard.Claims = rootViewClaimReader{obs: observedFence}
197 if _, err := rootGuard.BeginFence(ctx, receipt.Target); err != nil {
198 t.Fatal("root fence setup", err)
199 }
200 if err := rootGuard.CompleteFence(ctx, receipt.Target, receipt.Bytes, receipt.EvidenceAt, func(_ context.Context, target hostguard.FenceTarget, body []byte, at time.Time) error {
201 return verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: target, Bytes: body, EvidenceAt: at})
202 }); err != nil {
203 t.Fatal("root receipt setup", err)
204 }
205 rootGuard.Claims = f.guard.Claims
206 coordinator := f.root(recoveryStopDrain{})
207 coordinator.Guard = rootGuard
208 root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Box: "box-1", Enrollment: "enrollment-1", Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")}}}
209 root.servingParks = parkRuntime
210 root.RequireNewWork = func(context.Context) error { return nil }
211 root.InventoryActor = func(context.Context) (hostbox.Actor, error) {
212 actor := restarted.local.Actor
213 actor.Eligible = true
214 return actor, nil
215 }
216 socket := startRetirementRecoveryTransport(t, root)
217 unauthenticated := root
218 unauthenticated.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("unknown requester") }
219 refusedSocket := startRetirementRecoveryTransport(t, unauthenticated)
220 hook := coldAdmissionHook(f.guard, handoverRecoveryIdentity(t, restarted.local.Actor), socket)
221 restarted.wake.AfterAdmission = func(ctx context.Context, before, after hostclaim.Observed) error {
222 h := after.Claim.Holder
223 request := coldHandoverRequest{Prior: receipt.Target, Next: hostbox.ColdSuccessor{Host: after.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Generation: h.Generation, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: recoveryFixtureBinding, ETag: after.ETag, Op: after.Claim.Op.ID, Phase: after.Claim.Op.Phase}}
224 if status := postRecoveryHandover(t, refusedSocket, request); status != http.StatusForbidden {
225 t.Fatalf("receipt authenticated an unknown requester: %d", status)
226 }
227 for _, field := range []string{"controller", "boot", "revision", "box", "enrollment"} {
228 forged := request
229 switch field {
230 case "controller":
231 forged.Prior.ControllerID = "unknown-controller"
232 case "boot":
233 forged.Prior.BootID = "unknown-boot"
234 case "revision":
235 forged.Prior.DirectoryRevision = "8"
236 case "box":
237 forged.Prior.Box = "other-box"
238 case "enrollment":
239 forged.Prior.Enrollment = "other-enrollment"
240 }
241 if status := postRecoveryHandover(t, socket, forged); status != http.StatusBadRequest {
242 t.Fatalf("forged %s accepted: %d", field, status)
243 }
244 }
245 if row, err := reg.Get(route.Host); err != nil || row == nil || row.ClaimRowID != route.ClaimRowID {
246 t.Fatalf("refusal changed predecessor row: %+v %v", row, err)
247 }
248 if status := postRecoveryHandover(t, socket, request); status != http.StatusNoContent {
249 t.Fatalf("exact completed predecessor receipt refused: HTTP %d", status)
250 }
251 return hook(ctx, before, after)
252 }
253 // The fixture supplies the lease; the real Admit invokes root's cold
254 // handover before activation, prelaunch persistence and retry binding.
255 f.now = f.retry().NotBefore
256 next := restarted.admit(t, f)
257 if next.Claim.Holder.Session <= admitted.Claim.Holder.Session {
258 t.Fatal("next wake reused predecessor session")
259 }
260 if retry := f.retry(); retry.AttemptsReserved != 2 || retry.Attempt == nil || retry.Attempt.ControllerID != "controller-2" || retry.Attempt.BootID != boot {
261 t.Fatalf("next attempt not charged to current requester: %+v", retry)
262 }
263 currentRow, err := reg.Get(route.Host)
264 if err != nil || currentRow == nil || currentRow.ClaimStamp != next.Claim.Holder.Stamp || currentRow.ClaimRowID == route.ClaimRowID {
265 t.Fatalf("next wake ownership: %+v %v", currentRow, err)
266 }
267 prepared, err := restarted.runtime.Prepare(ctx, *currentRow, next, recoveryPolicy)
268 if err != nil {
269 t.Fatal(err)
270 }
271 if err = prepared.Restore(ctx); err != nil {
272 t.Fatalf("next wake restore: %v", err)
273 }
274 marker := filepath.Join(f.guard.DataDir, "state", "data.db.gen")
275 if body, err := os.ReadFile(marker); err != nil || strings.TrimSpace(string(body)) != next.Claim.Holder.Generation {
276 t.Fatalf("next wake generation: %q %v", body, err)
277 }
278 replayed, err := rootGuard.FenceReceiptFor(ctx, receipt.Target)
279 if err != nil || !bytes.Equal(replayed.Bytes, receipt.Bytes) || !replayed.EvidenceAt.Equal(receipt.EvidenceAt) || replayed.Target != receipt.Target {
280 t.Fatalf("handover rewrote predecessor evidence: %+v %v", replayed, err)
281 }
282 launch, err := f.journal.Read(ctx, recoveryTuple(next, restarted.local))
283 if err != nil || launch.ControllerPID != 4202 || launch.ProcessStart != "start-2" || launch.Target.DirectoryRevision != recoveryFixtureBinding {
284 t.Fatalf("next wake launch tuple: %+v %v", launch, err)
285 }
286 })
287 }
288}
289