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 "fmt"
8 "os/exec"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/genselect"
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "github.com/carlosframework/platform/internal/lease/leasetest"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/superfly/ltx"
21)
22
23type plan9D5Owner struct {
24 name string
25 calls *[]string
26}
27
28func (o plan9D5Owner) note(call string) { *o.calls = append(*o.calls, o.name+"."+call) }
29func (o plan9D5Owner) Select(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) (ClaimLaunchInputs, error) {
30 o.note("select")
31 return ClaimLaunchInputs{}, nil
32}
33func (o plan9D5Owner) Materialize(context.Context, hostclaim.Observed, ClaimLaunchInputs, hostwrite.Work) error {
34 o.note("materialize")
35 return nil
36}
37func (o plan9D5Owner) EmptyAuthority(context.Context, hostclaim.Observed, ClaimLaunchInputs) error {
38 o.note("empty")
39 return nil
40}
41func (o plan9D5Owner) Publish(context.Context, hostclaim.Observed, ClaimLaunchInputs) error {
42 o.note("publish")
43 return nil
44}
45func (o plan9D5Owner) owners() ClaimKindOwners {
46 return ClaimKindOwners{Inputs: o,
47 AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error {
48 o.note("admission")
49 return nil
50 },
51 PublicationEvidence: func(context.Context, hostclaim.Observed) error { o.note("publication"); return nil }}
52}
53
54func plan9D5Kind(kind string) hostclaim.Observed {
55 id := "op"
56 if kind == hostclaim.KindServing {
57 id = ""
58 }
59 return hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: kind, ID: id}}}
60}
61
62func plan9D5Call(d ClaimOwnerDispatch, o hostclaim.Observed) error {
63 ctx := context.Background()
64 _, err := d.Select(ctx, registry.Route{}, o, activator.StartPolicy{})
65 return errors.Join(err, d.Materialize(ctx, o, ClaimLaunchInputs{}, nil), d.EmptyAuthority(ctx, o, ClaimLaunchInputs{}), d.Publish(ctx, o, ClaimLaunchInputs{}),
66 d.AdmissionEvidence(ctx, o, hostclaim.Admission{}, nil), d.PublicationEvidence(ctx, o))
67}
68
69func TestPlan9D5OwnerDispatchRoutesByOpKind(t *testing.T) {
70 var calls []string
71 moves := plan9D5Owner{"moves", &calls}.owners()
72 d := ClaimOwnerDispatch{Serving: plan9D5Owner{"serving", &calls}.owners(), Moves: &moves}
73 all := "select,materialize,empty,publish,admission,publication"
74 for kind, owner := range map[string]string{hostclaim.KindServing: "serving", hostclaim.KindMoving: "moves", hostclaim.KindLanding: "moves", hostclaim.KindReverting: "moves", hostclaim.KindRestoring: "moves"} {
75 calls = nil
76 if err := plan9D5Call(d, plan9D5Kind(kind)); err != nil {
77 t.Fatalf("%s: %v", kind, err)
78 }
79 if got, want := strings.Join(calls, ","), owner+"."+strings.ReplaceAll(all, ",", ","+owner+"."); got != want {
80 t.Fatalf("%s routed %s, want %s", kind, got, want)
81 }
82 }
83 for _, kind := range []string{hostclaim.KindCreating, hostclaim.KindDeleting, hostclaim.KindParking, hostclaim.KindPurging, hostclaim.KindPlacing, hostclaim.KindRelocating, hostclaim.KindPushing} {
84 calls = nil
85 if err := plan9D5Call(d, plan9D5Kind(kind)); !errors.Is(err, hostbox.ErrUnsupported) || len(calls) != 0 {
86 t.Fatalf("%s: %v %v", kind, err, calls)
87 }
88 }
89 // Moves off or unowned: the move kinds keep main's typed refusal and
90 // serving is unaffected.
91 d.Moves = nil
92 var refusal *ClaimDependencyError
93 calls = nil
94 if err := plan9D5Call(d, plan9D5Kind(hostclaim.KindLanding)); !errors.As(err, &refusal) || len(calls) != 0 {
95 t.Fatalf("unowned landing: %v %v", err, calls)
96 }
97 if err := plan9D5Call(d, plan9D5Kind(hostclaim.KindServing)); err != nil {
98 t.Fatal(err)
99 }
100 if err := plan9D5Call(ClaimOwnerDispatch{}, plan9D5Kind(hostclaim.KindServing)); !errors.Is(err, hostwrite.ErrUnavailable) {
101 t.Fatalf("missing serving owners: %v", err)
102 }
103}
104
105func TestPlan9D5MovesIntegrationNeedsBothFlagsAndACompleteOwnerSet(t *testing.T) {
106 var calls []string
107 complete := ClaimMovesOwners{ClaimKindOwners: plan9D5Owner{"moves", &calls}.owners(), Operations: &plan9D2Attempts{}}
108 env := func(v string) func(string) string {
109 return func(k string) string {
110 if k == "CARLOS_HOST_CLAIM" {
111 return "1"
112 }
113 if k == "CARLOS_MOVES_PHASE1" {
114 return v
115 }
116 return ""
117 }
118 }
119 for name, tc := range map[string]struct {
120 getenv func(string) string
121 owners ClaimMovesOwners
122 want bool
123 err error
124 }{
125 "claim-off": {func(k string) string {
126 if k == "CARLOS_MOVES_PHASE1" {
127 return "on"
128 }
129 return ""
130 }, complete, false, nil},
131 "moves-off": {env(""), complete, false, nil},
132 "moves-zero": {env("0"), complete, false, nil},
133 "no-owners": {env("on"), ClaimMovesOwners{}, false, nil},
134 "complete": {env("on"), complete, true, nil},
135 "no-attempts": {env("on"), ClaimMovesOwners{ClaimKindOwners: complete.ClaimKindOwners}, false, hostwrite.ErrUnavailable},
136 "no-publication": {env("1"), ClaimMovesOwners{ClaimKindOwners: ClaimKindOwners{Inputs: complete.Inputs, AdmissionEvidence: complete.AdmissionEvidence}, Operations: complete.Operations}, false, hostwrite.ErrUnavailable},
137 "probe-check-only": {env("true"), ClaimMovesOwners{ProbeReady: func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }}, false, hostwrite.ErrUnavailable},
138 } {
139 got, err := claimMovesC1(tc.getenv, tc.owners)
140 if got != tc.want || !errors.Is(err, tc.err) {
141 t.Fatalf("%s: %v %v", name, got, err)
142 }
143 }
144}
145
146// plan9D5Markers is an If-Match marker store over the versioned fixture.
147type plan9D5Markers struct{ b *leasetest.VersionedBucket }
148
149func (m plan9D5Markers) PutMarker(ctx context.Context, bucket, key string, body []byte, expected string, check func(context.Context) error) (string, error) {
150 if err := check(ctx); err != nil {
151 return "", err
152 }
153 versions, err := m.b.ListVersions(ctx, bucket, key)
154 if err != nil {
155 return "", err
156 }
157 current := ""
158 for _, v := range versions {
159 if v.Key == key && v.IsLatest && !v.DeleteMarker {
160 current = v.ETag
161 }
162 }
163 if current != expected {
164 return "", hostbox.ErrChanged
165 }
166 return m.b.Put(bucket, key, body), nil
167}
168
169type plan9D5Serving struct {
170 owners *claimServingOwners
171 bucket *leasetest.VersionedBucket
172 launch ClaimServingLaunch
173 seq int
174 files []string
175 filesErr error
176 synced int
177}
178
179// plan9D5Files is a C8 files provider that records each establishment.
180type plan9D5Files struct{ f *plan9D5Serving }
181
182func (p plan9D5Files) Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
183 if err := work.With(ctx, in.Identity, func() error { return nil }); err != nil {
184 return err
185 }
186 p.f.files = append(p.f.files, o.Claim.Holder.Generation+"/"+in.Admission.NewGeneration)
187 return p.f.filesErr
188}
189
190// publication is a fenced publication whose sync is counted and whose
191// fence always passes.
192func (f *plan9D5Serving) publication() ClaimPublication {
193 return ClaimPublication{Sync: func(context.Context) error { f.synced++; return nil }, Check: func(context.Context) error { return nil }}
194}
195
196func plan9D5Fixture(t *testing.T) *plan9D5Serving {
197 t.Helper()
198 b := leasetest.NewVersionedBucket()
199 b.AddBucket("data")
200 f := &plan9D5Serving{bucket: b, launch: ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:config", Release: "sha256:release"}, Executable: "/opt/carlos/live/" + recoveryHost, ExecutableSHA256: strings.Repeat("b", 64), Args: []string{"--socket", "/run/app.sock"}, Env: []string{"A=1"}, Version: "v7"}}
201 f.owners = &claimServingOwners{Data: b, Bucket: "data", Region: "ie", Box: "box-1", Now: func() time.Time { return time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) },
202 NewID: func() string { f.seq++; return fmt.Sprintf("fresh-%d", f.seq) },
203 Sources: ClaimServingSources{Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) { return f.launch, nil }, Markers: plan9D5Markers{b},
204 Files: plan9D5Files{f}}}
205 return f
206}
207
208func (f *plan9D5Serving) file(t *testing.T, gen string, level int, min, max uint64) {
209 t.Helper()
210 h := ltx.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, MinTXID: ltx.TXID(min), MaxTXID: ltx.TXID(max)}
211 body, err := h.MarshalBinary()
212 if err != nil {
213 t.Fatal(err)
214 }
215 f.bucket.Put("data", fmt.Sprintf("instances/%s/g-%s/%04x/%s", recoveryHost, gen, level, ltx.FormatFilename(ltx.TXID(min), ltx.TXID(max))), body)
216}
217
218func (f *plan9D5Serving) marker(t *testing.T, gen string) {
219 t.Helper()
220 body, err := json.Marshal(genselect.Marker{Op: gen, Incarnation: "inc", Host: recoveryHost, Box: "box-0", InstalledAt: time.Unix(1, 0).UTC()})
221 if err != nil {
222 t.Fatal(err)
223 }
224 f.bucket.Put("data", "instances/"+recoveryHost+"/generation.json", body)
225}
226
227func plan9D5Observed(h hostclaim.Holder) hostclaim.Observed {
228 h.Incarnation, h.Location = "inc", "instances/"+recoveryHost
229 return hostclaim.Observed{Host: recoveryHost, ETag: "etag", Claim: &hostclaim.Claim{Host: recoveryHost, Holder: &h, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-1"}}}
230}
231
232var plan9D5Route = registry.Route{Host: recoveryHost, DBPath: "/data/" + recoveryHost + "/data.db"}
233
234func TestPlan9D5ServingEmptyBootstrap(t *testing.T) {
235 f := plan9D5Fixture(t)
236 ctx := t.Context()
237 created := plan9D5Observed(hostclaim.Holder{Stamp: 3})
238 in, err := f.owners.Select(ctx, plan9D5Route, created, activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second})
239 if err != nil {
240 t.Fatal(err)
241 }
242 a := in.Admission
243 if !in.Empty || in.Baseline != nil || a.Mode != "cold" || a.NewGeneration != "fresh-1" || a.RemoteMaxTXID != 0 || a.PredecessorRegion != "" || a.KeepStamp || a.Box != "box-1" || a.Region != "ie" || in.Identity != f.launch.Identity || in.DBPath != plan9D5Route.DBPath || len(in.Roles) != 1 || in.Roles[0] != "main" {
244 t.Fatalf("empty bootstrap inputs: %+v", in)
245 }
246 if err := f.owners.admissionEvidence(ctx, created, a, nil); err != nil {
247 t.Fatal(err)
248 }
249 for name, mutate := range map[string]func(*hostclaim.Admission){
250 "keep-stamp": func(a *hostclaim.Admission) { a.KeepStamp = true },
251 "certified": func(a *hostclaim.Admission) { a.PredecessorCertified = true },
252 "coverage": func(a *hostclaim.Admission) { a.Coverage = "sealed" },
253 "region": func(a *hostclaim.Admission) { a.PredecessorRegion = "ie" },
254 "op": func(a *hostclaim.Admission) { a.ObservedOpID = "m1" },
255 "generation": func(a *hostclaim.Admission) { a.NewGeneration = hostclaim.RootGeneration },
256 "other-box": func(a *hostclaim.Admission) { a.Box = "box-2" },
257 } {
258 changed := a
259 mutate(&changed)
260 if err := f.owners.admissionEvidence(ctx, created, changed, nil); !errors.Is(err, hostbox.ErrChanged) {
261 t.Fatalf("%s: %v", name, err)
262 }
263 }
264 admitted := plan9D5Observed(hostclaim.Holder{Stamp: 5, Session: 5, Generation: "fresh-1", WriterBox: "box-1", Admissions: []hostclaim.AdmissionRecord{{Session: 5, Box: "box-1", Generation: "fresh-1", Region: "ie", Mode: "cold"}}})
265 if err := f.owners.EmptyAuthority(ctx, admitted, in); err != nil {
266 t.Fatal(err)
267 }
268 f.file(t, "elsewhere", 0, 1, 1)
269 if err := f.owners.EmptyAuthority(ctx, admitted, in); !errors.Is(err, hostbox.ErrChanged) {
270 t.Fatalf("a nonempty location passed as empty: %v", err)
271 }
272 f.bucket.FailOn = func(op, _ string) error {
273 if op == "list" {
274 return errors.New("listing failed")
275 }
276 return nil
277 }
278 if err := f.owners.EmptyAuthority(ctx, admitted, in); err == nil {
279 t.Fatal("a listing error passed as empty")
280 }
281}
282
283func TestPlan9D5ServingSelectRefusals(t *testing.T) {
284 f := plan9D5Fixture(t)
285 ctx := t.Context()
286 created := plan9D5Observed(hostclaim.Holder{Stamp: 3})
287 if _, err := f.owners.Select(ctx, plan9D5Route, created, activator.StartPolicy{Probe: true}); !errors.Is(err, hostbox.ErrUnsupported) {
288 t.Fatalf("probe: %v", err)
289 }
290 if _, err := f.owners.Select(ctx, registry.Route{Host: recoveryHost, DBPath: "relative.db"}, created, activator.StartPolicy{}); !errors.Is(err, hostbox.ErrUnsupported) {
291 t.Fatalf("relative database: %v", err)
292 }
293 landing := created
294 landing.Claim = &hostclaim.Claim{Host: recoveryHost, Holder: created.Claim.Holder, Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: "committed", Exec: "box-1"}}
295 if _, err := f.owners.Select(ctx, plan9D5Route, landing, activator.StartPolicy{}); !errors.Is(err, hostbox.ErrChanged) {
296 t.Fatalf("serving owners answered a landing claim: %v", err)
297 }
298 f.launch.ExecutableSHA256 = "short"
299 if _, err := f.owners.Select(ctx, plan9D5Route, created, activator.StartPolicy{}); !errors.Is(err, hostwrite.ErrUnavailable) {
300 t.Fatalf("bad digest: %v", err)
301 }
302 if _, err := f.owners.launchDigest(ctx, plan9D5Route); !errors.Is(err, hostwrite.ErrUnavailable) {
303 t.Fatalf("bad digest in the retry inputs: %v", err)
304 }
305}
306
307func TestPlan9D5ServingBaselinePublicationAndEvidence(t *testing.T) {
308 f := plan9D5Fixture(t)
309 ctx := t.Context()
310 f.file(t, "g0", 9, 1, 11)
311 f.marker(t, "g0")
312 serving := plan9D5Observed(hostclaim.Holder{Stamp: 4, Session: 4, Generation: "g0", WriterBox: "box-1"})
313 in, err := f.owners.Select(ctx, plan9D5Route, serving, activator.StartPolicy{})
314 if err != nil {
315 t.Fatal(err)
316 }
317 if in.Empty || in.Baseline == nil || in.Baseline.Generation != "g0" || in.Baseline.MaxTXID != 11 || in.Admission.RemoteMaxTXID != 11 || in.Admission.PredecessorRegion != "ie" || in.Admission.ObservedGeneration != "g0" || in.Admission.ObservedSession != 4 {
318 t.Fatalf("serving inputs: %+v", in)
319 }
320 if err := f.owners.admissionEvidence(ctx, serving, in.Admission, in.Baseline); err != nil {
321 t.Fatal(err)
322 }
323 stale := *in.Baseline
324 stale.MaxTXID = 10
325 if err := f.owners.admissionEvidence(ctx, serving, in.Admission, &stale); !errors.Is(err, hostbox.ErrChanged) {
326 t.Fatalf("a baseline that is not the certified remote: %v", err)
327 }
328 if err := f.owners.admissionEvidence(ctx, serving, in.Admission, nil); !errors.Is(err, hostbox.ErrChanged) {
329 t.Fatalf("no baseline: %v", err)
330 }
331 // After the admission CAS, publication waits for the generation's root.
332 admitted := plan9D5Observed(hostclaim.Holder{Stamp: 7, Session: 7, Generation: in.Admission.NewGeneration, WriterBox: "box-1",
333 Admissions: []hostclaim.AdmissionRecord{{Session: 7, Box: "box-1", Generation: in.Admission.NewGeneration, Predecessor: "g0", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}}})
334 before := f.bucket.LatestID("data", "instances/"+recoveryHost+"/generation.json")
335 if _, err := f.owners.PublishFenced(ctx, admitted, in, f.publication()); !errors.Is(err, hostbox.ErrChanged) || f.synced != 1 {
336 t.Fatalf("a marker before the root: %v synced=%d", err, f.synced)
337 }
338 // The unfenced entry never writes a serving marker.
339 if err := f.owners.Publish(ctx, admitted, in); !errors.Is(err, hostwrite.ErrUnavailable) {
340 t.Fatalf("unfenced publication: %v", err)
341 }
342 if f.bucket.LatestID("data", "instances/"+recoveryHost+"/generation.json") != before {
343 t.Fatal("the marker moved before the root existed")
344 }
345 if err := f.owners.publicationEvidence(ctx, admitted); !errors.Is(err, hostbox.ErrChanged) {
346 t.Fatalf("publication evidence without a published marker: %v", err)
347 }
348 f.file(t, in.Admission.NewGeneration, 0, 1, 1)
349 receipt, err := f.owners.PublishFenced(ctx, admitted, in, f.publication())
350 if err != nil {
351 t.Fatal(err)
352 }
353 root := fmt.Sprintf("instances/%s/g-%s/0000/%s", recoveryHost, in.Admission.NewGeneration, ltx.FormatFilename(1, 1))
354 if receipt.MarkerVersion == "" || receipt.Root.Key != root || receipt.Root.VersionID != f.bucket.LatestID("data", root) {
355 t.Fatalf("receipt: %+v", receipt)
356 }
357 if err := f.owners.publicationEvidence(ctx, admitted); err != nil {
358 t.Fatal(err)
359 }
360 body, ok := f.bucket.Current("data", "instances/"+recoveryHost+"/generation.json")
361 var m genselect.Marker
362 if !ok || json.Unmarshal(body, &m) != nil || m.Op != in.Admission.NewGeneration || m.Box != "box-1" || m.Incarnation != "inc" || m.SnapshotTXID != 0 {
363 t.Fatalf("published marker: %s", body)
364 }
365 // A replay of the same session's publication writes nothing new.
366 written := f.bucket.LatestID("data", "instances/"+recoveryHost+"/generation.json")
367 if replay, err := f.owners.PublishFenced(ctx, admitted, in, f.publication()); err != nil || replay != receipt || f.bucket.LatestID("data", "instances/"+recoveryHost+"/generation.json") != written {
368 t.Fatalf("replay: %+v %v", replay, err)
369 }
370 // Another writer's marker replaces ours: the evidence no longer holds.
371 f.marker(t, in.Admission.NewGeneration)
372 if err := f.owners.publicationEvidence(ctx, admitted); !errors.Is(err, hostbox.ErrChanged) {
373 t.Fatalf("evidence for a marker this session did not write: %v", err)
374 }
375}
376
377func TestPlan9D5ServingPublishSupersedesOnlyTheObservedMarker(t *testing.T) {
378 f := plan9D5Fixture(t)
379 ctx := t.Context()
380 f.file(t, "g0", 9, 1, 11)
381 f.marker(t, "g0")
382 in := ClaimLaunchInputs{Admission: hostclaim.Admission{NewGeneration: "g1"}}
383 admitted := plan9D5Observed(hostclaim.Holder{Stamp: 7, Session: 7, Generation: "g1", WriterBox: "box-1",
384 Admissions: []hostclaim.AdmissionRecord{{Session: 7, Box: "box-1", Generation: "g1", Predecessor: "g0", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}}})
385 f.file(t, "g1", 0, 1, 1)
386 // The conditional write loses to a marker written after its read.
387 f.owners.Sources.Markers = plan9D5Racing{plan9D5Markers{f.bucket}, func() {
388 f.bucket.Put("data", "instances/"+recoveryHost+"/generation.json", []byte(`{"op":"other"}`))
389 }}
390 if _, err := f.owners.PublishFenced(ctx, admitted, in, f.publication()); !errors.Is(err, hostbox.ErrChanged) {
391 t.Fatalf("a raced marker write: %v", err)
392 }
393}
394
395type plan9D5Racing struct {
396 plan9D5Markers
397 race func()
398}
399
400func (r plan9D5Racing) PutMarker(ctx context.Context, bucket, key string, body []byte, expected string, check func(context.Context) error) (string, error) {
401 r.race()
402 return r.plan9D5Markers.PutMarker(ctx, bucket, key, body, expected, check)
403}
404
405type plan9D5Work struct {
406 identities *[]hostwrite.Identity
407 err error
408}
409
410func (w plan9D5Work) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
411 *w.identities = append(*w.identities, id)
412 if w.err != nil {
413 return w.err
414 }
415 return fn()
416}
417
418func TestPlan9D5ServingMaterializeChecksIdentityThenFiles(t *testing.T) {
419 f := plan9D5Fixture(t)
420 ctx := t.Context()
421 admitted := plan9D5Observed(hostclaim.Holder{Stamp: 7, Session: 7, Generation: "g1", WriterBox: "box-1"})
422 in := ClaimLaunchInputs{Identity: f.launch.Identity, Admission: hostclaim.Admission{NewGeneration: "g1"}}
423 var seen []hostwrite.Identity
424 if err := f.owners.Materialize(ctx, admitted, in, plan9D5Work{identities: &seen}); err != nil {
425 t.Fatal(err)
426 }
427 if len(seen) != 2 || seen[0] != f.launch.Identity || seen[1] != f.launch.Identity || len(f.files) != 1 || f.files[0] != "g1/g1" {
428 t.Fatalf("identity checks %v, files %v", seen, f.files)
429 }
430 // A refused identity establishes no files manifest.
431 refused := errors.New("held identity changed")
432 if err := f.owners.Materialize(ctx, admitted, in, plan9D5Work{identities: &seen, err: refused}); !errors.Is(err, refused) || len(f.files) != 1 {
433 t.Fatalf("refused identity: %v files=%v", err, f.files)
434 }
435 f.filesErr = errors.New("no valid predecessor manifest")
436 if err := f.owners.Materialize(ctx, admitted, in, plan9D5Work{identities: &seen}); !errors.Is(err, f.filesErr) {
437 t.Fatalf("files failure: %v", err)
438 }
439 if err := f.owners.Materialize(ctx, admitted, ClaimLaunchInputs{}, plan9D5Work{identities: &seen}); !errors.Is(err, hostwrite.ErrUnavailable) {
440 t.Fatalf("no identity: %v", err)
441 }
442 f.owners.Sources.Files = nil
443 var refusal *ClaimDependencyError
444 if err := f.owners.Materialize(ctx, admitted, in, plan9D5Work{identities: &seen}); !errors.As(err, &refusal) || !errors.Is(err, hostwrite.ErrUnavailable) {
445 t.Fatalf("no files owner: %v", err)
446 }
447}
448