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 "bufio"
7 "bytes"
8 "context"
9 "database/sql"
10 "errors"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostbox"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/hostguard"
20 "github.com/carlosframework/platform/internal/hostwrite"
21 "github.com/carlosframework/platform/internal/park"
22 "github.com/carlosframework/platform/internal/provision"
23 "github.com/carlosframework/platform/internal/registry"
24 "github.com/carlosframework/platform/internal/release"
25)
26
27func TestClaimProductionCensusInstallsThroughReconciler(t *testing.T) {
28 for _, flag := range []string{"", "1", "0"} {
29 for _, mode := range []string{"empty", "fenced"} {
30 name := "flag-off/" + mode
31 if flag != "" {
32 name = "flag-on-" + flag + "/" + mode
33 }
34 t.Run(name, func(t *testing.T) {
35 t.Setenv("CARLOS_HOST_CLAIM", flag)
36 testClaimProductionCensusInstallation(t, mode == "fenced")
37 })
38 }
39 }
40}
41
42// claimCensusInstallLinks is the test's own reading of a unit's [Install]
43// section: the symlinks `systemctl enable` makes, relative to the unit
44// directory. It fails on a key it does not model rather than under-enable.
45func claimCensusInstallLinks(t *testing.T, unit string, body []byte) []string {
46 t.Helper()
47 var links []string
48 install := false
49 scan := bufio.NewScanner(bytes.NewReader(body))
50 for scan.Scan() {
51 line := strings.TrimSpace(scan.Text())
52 switch {
53 case line == "" || line[0] == '#' || line[0] == ';':
54 continue
55 case line[0] == '[':
56 install = line == "[Install]"
57 continue
58 case !install:
59 continue
60 }
61 key, value, _ := strings.Cut(line, "=")
62 for _, v := range strings.Fields(value) {
63 switch strings.TrimSpace(key) {
64 case "WantedBy":
65 links = append(links, v+".wants/"+unit)
66 case "RequiredBy":
67 links = append(links, v+".requires/"+unit)
68 case "Alias":
69 links = append(links, v)
70 default:
71 t.Fatalf("%s: [Install] %s is not modelled by the census fixture", unit, key)
72 }
73 }
74 }
75 return links
76}
77
78// claimCensusSystemctl stands in for systemctl over a staging root: enable
79// writes exactly the links the unit's [Install] metadata names, pointing at
80// the installed unit file, as `systemctl enable` does. Nothing reaches the
81// live system manager.
82func claimCensusSystemctl(t *testing.T, unitsDir string) func(args ...string) (string, error) {
83 return func(args ...string) (string, error) {
84 switch args[0] {
85 case "is-active":
86 return "inactive\n", nil
87 case "is-enabled":
88 return "disabled\n", nil
89 case "enable":
90 default:
91 return "", nil
92 }
93 for _, unit := range args[1:] {
94 target := filepath.Join(unitsDir, unit)
95 body, err := os.ReadFile(target)
96 if err != nil {
97 return "", err
98 }
99 for _, rel := range claimCensusInstallLinks(t, unit, body) {
100 link := filepath.Join(unitsDir, rel)
101 if old, err := os.Readlink(link); err == nil && old == target {
102 continue
103 }
104 if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
105 return "", err
106 }
107 if err := os.Symlink(target, link); err != nil {
108 return "", err
109 }
110 }
111 }
112 return "", nil
113 }
114}
115
116// claimCensusBox provisions a box under a staging root with the production
117// installers: bootstrap, the self-update applier's binary rename, unit
118// install and arming, ensureSystemUnits' second belt and the release store.
119func claimCensusBox(t *testing.T) ClaimRuntimeResources {
120 t.Helper()
121 ctx := t.Context()
122 root := t.TempDir()
123 stageEnv := func(stage string) {
124 t.Helper()
125 if err := os.MkdirAll(filepath.Join(stage, "etc", "carlos"), 0755); err != nil {
126 t.Fatal(err)
127 }
128 if err := os.WriteFile(filepath.Join(stage, "etc", "carlos", "host.env"), []byte("CARLOS_HOST_CLAIM="+os.Getenv("CARLOS_HOST_CLAIM")+"\n"), 0600); err != nil {
129 t.Fatal(err)
130 }
131 }
132 stageEnv(root)
133 if err := bootstrap(root, nil); err != nil {
134 t.Fatal(err)
135 }
136 etc := filepath.Join(root, "etc")
137 r := ClaimRuntimeResources{DataDir: filepath.Join(root, "data"), OptDir: filepath.Join(root, "opt", "carlos"), UnitsDir: filepath.Join(etc, "systemd", "system")}
138 systemctl := claimCensusSystemctl(t, r.UnitsDir)
139 a := &systemApplier{optDir: r.OptDir, dataDir: r.DataDir, etcDir: etc, ops: sysOps{
140 systemctl: systemctl,
141 run: func(context.Context, time.Duration, string, ...string) (string, error) { return "", nil },
142 logf: t.Logf,
143 }}
144 // Bootstrap never stages the binary; the applier renames a staged one
145 // to its production path.
146 if err := os.MkdirAll(a.stagingDir(), 0o700); err != nil {
147 t.Fatal(err)
148 }
149 staged := filepath.Join(a.stagingDir(), "carlos")
150 if err := os.WriteFile(staged, []byte("#!/bin/sh\necho carlos\n"), 0o755); err != nil {
151 t.Fatal(err)
152 }
153 if err := os.Rename(staged, systemBinaryPath(r.OptDir)); err != nil {
154 t.Fatal(err)
155 }
156 if err := copyFileDurable(systemBinaryPath(r.OptDir), systemPrevPath(r.OptDir), 0o755); err != nil {
157 t.Fatal(err)
158 }
159 if err := os.WriteFile(prevVersionPath(r.OptDir), []byte("v0.1.0\n"), 0o644); err != nil {
160 t.Fatal(err)
161 }
162 if err := os.WriteFile(prevAllowedPath(r.OptDir), []byte("v0.1.0\n"), 0o644); err != nil {
163 t.Fatal(err)
164 }
165 if err := os.WriteFile(seedsPrevAllowedPath(r.OptDir), []byte("v0.1.0\n"), 0o644); err != nil {
166 t.Fatal(err)
167 }
168 newDir := filepath.Join(a.stagingDir(), "new")
169 stageEnv(newDir)
170 if err := bootstrap(newDir, nil); err != nil {
171 t.Fatal(err)
172 }
173 if err := writeJSONDurable(filepath.Join(newDir, "plan.json"), currentBootstrapPlan(), 0o600); err != nil {
174 t.Fatal(err)
175 }
176 if err := a.installUnits(ctx, newDir, nil); err != nil {
177 t.Fatal(err)
178 }
179 plan, err := stagedPlan(newDir)
180 if err != nil {
181 t.Fatal(err)
182 }
183 if err := a.arm(plan); err != nil {
184 t.Fatal(err)
185 }
186 ensureSystemUnits(etc, a.lockPath(), systemctl, systemctl, t.Logf)
187 for _, unit := range plan.Enabled {
188 body, err := os.ReadFile(filepath.Join(r.UnitsDir, unit))
189 if err != nil {
190 t.Fatal(err)
191 }
192 for _, rel := range claimCensusInstallLinks(t, unit, body) {
193 if _, err := os.Readlink(filepath.Join(r.UnitsDir, rel)); err != nil {
194 t.Fatalf("fixture did not enable %s: %v", unit, err)
195 }
196 }
197 }
198 if hostwrite.Enabled(os.Getenv) {
199 // Rollout uses add-wants for this static service, which has no
200 // [Install] section. Model its exact boot link independently.
201 link := filepath.Join(r.UnitsDir, "multi-user.target.wants/carlos-instance-control.service")
202 if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
203 t.Fatal(err)
204 }
205 if err := os.Symlink(filepath.Join(r.UnitsDir, "carlos-instance-control.service"), link); err != nil {
206 t.Fatal(err)
207 }
208 }
209 for _, app := range []struct{ acct, ver, app string }{{"acct", "v1", "app"}, {"acct", "v2", "app"}, {"", "v1", "ops"}} {
210 if err := release.StageArtifact(r.OptDir, app.acct, app.ver, app.app, []byte("build "+app.ver)); err != nil {
211 t.Fatal(err)
212 }
213 }
214 for _, name := range []string{"hostguard", ".hostclaims"} {
215 if err := os.MkdirAll(filepath.Join(r.DataDir, name), 0o700); err != nil {
216 t.Fatal(err)
217 }
218 }
219 for unit, mode := range map[string]string{"carlos-edge.service": "edge", "carlos-system-update.service": "apply", "carlos-system-recover.service": "recover"} {
220 path := filepath.Join(r.UnitsDir, unit+".d", "90-host-floor.conf")
221 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
222 t.Fatal(err)
223 }
224 if err := os.WriteFile(path, []byte("[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec "+mode+"\n"), 0644); err != nil {
225 t.Fatal(err)
226 }
227 }
228 return r
229}
230
231// claimCensusMutation changes the box and returns how to put it back.
232type claimCensusMutation struct {
233 name string
234 mutate func(t *testing.T) func()
235}
236
237func claimCensusReplace(t *testing.T, path string, put func() error) func() {
238 t.Helper()
239 info, err := os.Lstat(path)
240 if err != nil {
241 t.Fatal(err)
242 }
243 var target string
244 var body []byte
245 if info.Mode()&os.ModeSymlink != 0 {
246 target, err = os.Readlink(path)
247 } else {
248 body, err = os.ReadFile(path)
249 }
250 if err != nil {
251 t.Fatal(err)
252 }
253 if err := os.Remove(path); err != nil {
254 t.Fatal(err)
255 }
256 if err := put(); err != nil {
257 t.Fatal(err)
258 }
259 return func() {
260 if err := os.RemoveAll(path); err != nil {
261 t.Fatal(err)
262 }
263 if target != "" {
264 err = os.Symlink(target, path)
265 } else {
266 err = os.WriteFile(path, body, info.Mode().Perm())
267 }
268 if err != nil {
269 t.Fatal(err)
270 }
271 }
272}
273
274func claimCensusCreate(t *testing.T, path string, put func() error) func() {
275 t.Helper()
276 // Restore removes the outermost directory this created, so a host-named
277 // drop-in directory does not outlive its case.
278 created := path
279 for dir := filepath.Dir(path); ; dir = filepath.Dir(dir) {
280 if _, err := os.Lstat(dir); err == nil {
281 break
282 }
283 created = dir
284 }
285 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
286 t.Fatal(err)
287 }
288 if err := put(); err != nil {
289 t.Fatal(err)
290 }
291 return func() {
292 if err := os.RemoveAll(created); err != nil {
293 t.Fatal(err)
294 }
295 }
296}
297
298func claimCensusMutations(r ClaimRuntimeResources, host string) []claimCensusMutation {
299 unit := func(name string) string { return filepath.Join(r.UnitsDir, name) }
300 edgeLink := unit("multi-user.target.wants/carlos-edge.service")
301 socketLink := unit("sockets.target.wants/carlos-instance-control.socket")
302 relink := func(link, target string) func(t *testing.T) func() {
303 return func(t *testing.T) func() {
304 return claimCensusReplace(t, link, func() error { return os.Symlink(target, link) })
305 }
306 }
307 newLink := func(link, target string) func(t *testing.T) func() {
308 return func(t *testing.T) func() {
309 return claimCensusCreate(t, link, func() error { return os.Symlink(target, link) })
310 }
311 }
312 newFile := func(path, body string) func(t *testing.T) func() {
313 return func(t *testing.T) func() {
314 return claimCensusCreate(t, path, func() error { return os.WriteFile(path, []byte(body), 0o644) })
315 }
316 }
317 live := func(h string) func(t *testing.T) func() {
318 return func(t *testing.T) func() {
319 link := release.LiveLink(r.OptDir, h)
320 return claimCensusCreate(t, link, func() error { return release.PointLive(r.OptDir, h, "acct", "v1", "app") })
321 }
322 }
323 mutations := []claimCensusMutation{
324 {"unknown guarded-edge drop-in", newFile(unit("carlos-edge.service.d/91-unknown.conf"), "[Service]\nExecStart=/bin/true\n")},
325 {"guard drop-in symlink", relink(unit("carlos-edge.service.d/90-host-floor.conf"), "/missing/guard")},
326 {"unknown data file", newFile(filepath.Join(r.DataDir, "unclassified.db"), "old")},
327 {"unknown opt file", newFile(filepath.Join(r.OptDir, "unexplained"), "old")},
328 {"unknown vendor link", newLink(unit("multi-user.target.wants/ssh.service"), "/usr/lib/systemd/system/ssh.service")},
329 {"dangling unknown link", newLink(unit("multi-user.target.wants/unknown.service"), "/missing/unit.service")},
330 {"unknown unit", newFile(unit("backup.service"), "[Service]\nExecStart=/bin/true\n")},
331 {"unknown platform unit", newFile(unit("carlos-extra.service"), "[Service]\nExecStart=/bin/true\n")},
332 {"selected host live link", live(host)},
333 {"another host live link", live("other.example")},
334 {"enable link to the host live link", relink(edgeLink, release.LiveLink(r.OptDir, host))},
335 {"enable link to another platform unit", relink(edgeLink, unit("carlos-app@.service"))},
336 {"enable link to a host unit instance", relink(socketLink, unit("carlos-app@"+host+".socket"))},
337 {"relative enable link", relink(edgeLink, "../carlos-edge.service")},
338 {"enable link under the wrong target", newLink(unit("timers.target.wants/carlos-edge.service"), unit("carlos-edge.service"))},
339 {"masked platform unit", relink(unit("carlos-edge.service"), "/dev/null")},
340 {"platform unit symlinked to a release", relink(unit("carlos-edge.service"), release.VersionPath(r.OptDir, "acct", "v1", "app"))},
341 {"enabled host unit instance", newLink(unit("multi-user.target.wants/carlos-app@other.example.service"), unit("carlos-app@.service"))},
342 {"host unit drop-in", newFile(unit("carlos-app@"+host+".service.d/10-caps.conf"), "[Service]\n")},
343 {"platform unit drop-in", newFile(unit("carlos-edge.service.d/override.conf"), "[Service]\n")},
344 {"binary symlinked to a release", relink(systemBinaryPath(r.OptDir), release.VersionPath(r.OptDir, "acct", "v1", "app"))},
345 {"symlink in the release store", newLink(release.VersionPath(r.OptDir, "acct", "v1", "shell"), "/bin/sh")},
346 {"host-named release", newFile(release.VersionPath(r.OptDir, "acct", "v1", host), "build")},
347 {"foreign link into a host live link", newLink(unit("multi-user.target.wants/backup2.service"), release.LiveLink(r.OptDir, "other.example"))},
348 {"foreign link into the data root", newLink(unit("multi-user.target.wants/backup3.service"), filepath.Join(r.DataDir, "unit.service"))},
349 {"foreign unit naming the platform", newFile(unit("evil.service"), "[Service]\nExecStart="+release.LiveLink(r.OptDir, "other.example")+"\n")},
350 {"foreign unit naming a managed root", newFile(unit("export.service"), "[Service]\nExecStart=/usr/bin/cp "+filepath.Join(r.DataDir, "registry.db")+" /srv\n")},
351 }
352 rootBoot := unit("multi-user.target.wants/carlos-instance-control.service")
353 rootUnit := unit("carlos-instance-control.service")
354 mutations = append(mutations,
355 claimCensusMutation{"root service under wrong target", newLink(unit("timers.target.wants/carlos-instance-control.service"), rootUnit)},
356 claimCensusMutation{"root service masked", relink(rootUnit, "/dev/null")},
357 )
358 if hostwrite.Enabled(os.Getenv) {
359 mutations = append(mutations,
360 claimCensusMutation{"root boot link to another unit", relink(rootBoot, unit("carlos-edge.service"))},
361 claimCensusMutation{"root boot link to host instance", relink(rootBoot, unit("carlos-app@"+host+".service"))},
362 claimCensusMutation{"root boot relative link", relink(rootBoot, "../carlos-instance-control.service")},
363 claimCensusMutation{"root boot regular file", func(t *testing.T) func() {
364 return claimCensusReplace(t, rootBoot, func() error { return os.WriteFile(rootBoot, []byte("unit"), 0o644) })
365 }},
366 )
367 } else {
368 mutations = append(mutations, claimCensusMutation{"root boot link while flag off", newLink(rootBoot, rootUnit)})
369 }
370 return mutations
371}
372
373func testClaimProductionCensusInstallation(t *testing.T, fenced bool) {
374 ctx := t.Context()
375 r := claimCensusBox(t)
376 reg, err := registry.Open(filepath.Join(r.DataDir, "registry.db"))
377 if err != nil {
378 t.Fatal(err)
379 }
380 defer reg.Close()
381 r.Registry = reg
382 inventory, err := openLegacyInventory(t, filepath.Join(r.DataDir, ".hostclaims", "inventory.db"))
383 if err != nil {
384 t.Fatal(err)
385 }
386 defer inventory.Close()
387 launches, err := hostbox.OpenLaunchJournal(filepath.Join(r.DataDir, ".hostclaims", "launches.db"))
388 if err != nil {
389 t.Fatal(err)
390 }
391 defer launches.Close()
392 store := release.NewDirStore(t.TempDir())
393 created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
394 record := provision.Record{Schema: 1, Host: "shop.example", Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: created}
395 if err := provision.CreateRecord(ctx, store, record); err != nil {
396 t.Fatal(err)
397 }
398 key := provision.RecordKey(record.Account, record.App, record.Host)
399 obs, err := hostclaim.Read(ctx, store, record.Host)
400 if err != nil {
401 t.Fatal(err)
402 }
403 obs, err = hostclaim.Update(ctx, store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: key, Account: record.Account, App: record.App, Host: record.Host, Region: record.Region, CreatedAt: created}, "box", nil, nil, "adopt", func() string { return "id" }))
404 if err != nil {
405 t.Fatal(err)
406 }
407 if fenced {
408 obs, err = hostclaim.Update(ctx, store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Region: "ie", Mode: "cold", NewGeneration: "generation"}))
409 if err != nil {
410 t.Fatal(err)
411 }
412 }
413 guard := hostguard.Guard{DataDir: r.DataDir, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: store}}
414 source := hostbox.WorkSource{Guard: guard, Box: "box", Region: "ie", Now: time.Now,
415 Policy: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error {
416 return hostbox.Serving(c, s)
417 },
418 Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil },
419 }
420 census := &hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1",
421 Roots: map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir}, SharedPaths: claimGlobalSharedPaths(r),
422 }
423 adapter := hostbox.ReconcileAdapter{Registry: reg, Inventory: inventory, Census: census, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1",
424 Reconciler: hostbox.Reconciler{Claims: store, Guard: guard, Local: park.Local{Guard: guard, Claims: store, DataDir: r.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: time.Now, CheckLocal: census.CheckLocal},
425 }
426 selected := provision.ClaimRecord{Host: record.Host, Key: key, Family: "instance", Account: record.Account, App: record.App, CreatedAt: created, Value: record, Route: registry.Route{Host: record.Host, App: record.App, Account: record.Account, Kind: "instance", Channel: "stable"}}
427 guardPath := filepath.Join(r.DataDir, "hostguard", record.Host+".json")
428 if _, err := os.Stat(guardPath); !errors.Is(err, os.ErrNotExist) {
429 t.Fatalf("guard was active before installation: %v", err)
430 }
431 if fenced {
432 if err := guard.Activate(ctx, record.Host, "box", nil); err != nil {
433 t.Fatal(err)
434 }
435 h := obs.Claim.Holder
436 target := hostguard.FenceTarget{FenceID: "fence", Host: record.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "box", Region: "ie", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: obs.ETag, DirectoryRevision: "1"}
437 if _, err := guard.BeginFence(ctx, target); err != nil {
438 t.Fatal(err)
439 }
440 before, err := os.ReadFile(guardPath)
441 if err != nil {
442 t.Fatal(err)
443 }
444 if err := adapter.Install(ctx, selected, func(registry.ClaimOwnership) error { t.Fatal("fenced installation wrote a route"); return nil }); !errors.Is(err, hostguard.ErrFenced) {
445 t.Fatalf("fenced installation: %v", err)
446 }
447 after, err := os.ReadFile(guardPath)
448 if err != nil || !bytes.Equal(before, after) {
449 t.Fatalf("installation changed the durable fence: %v", err)
450 }
451 if row, err := reg.Get(record.Host); err != nil || row != nil {
452 t.Fatalf("fenced installation wrote a row: %+v %v", row, err)
453 }
454 if _, err := inventory.ReadInstallation(ctx, record.Host, h.Incarnation, h.Stamp); !errors.Is(err, sql.ErrNoRows) {
455 t.Fatalf("fenced installation wrote inventory: %v", err)
456 }
457 return
458 }
459 // Unknown files and substituted links still refuse before activation;
460 // the installers' shared artifacts do not make any of them eligible for
461 // a new ownership stamp.
462 for _, m := range claimCensusMutations(r, record.Host) {
463 restore := m.mutate(t)
464 if err := adapter.Install(ctx, selected, func(registry.ClaimOwnership) error { t.Fatalf("%s: admitted", m.name); return nil }); !errors.Is(err, hostbox.ErrTypedInventory) {
465 t.Fatalf("%s: refusal: %v", m.name, err)
466 }
467 if _, err := os.Stat(guardPath); !errors.Is(err, os.ErrNotExist) {
468 t.Fatalf("%s: refused installation activated guard: %v", m.name, err)
469 }
470 if row, err := reg.Get(record.Host); err != nil || row != nil {
471 t.Fatalf("%s: refused installation wrote a row: %+v %v", m.name, row, err)
472 }
473 if _, err := inventory.ReadInstallation(ctx, record.Host, obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp); !errors.Is(err, sql.ErrNoRows) {
474 t.Fatalf("%s: refused installation wrote inventory: %v", m.name, err)
475 }
476 restore()
477 }
478 writes := 0
479 install := func(owner registry.ClaimOwnership) error {
480 writes++
481 if !owner.Valid() {
482 t.Fatal("installation has no ownership")
483 }
484 if _, err := os.Stat(guardPath); err != nil {
485 t.Fatalf("installation did not activate the real guard record: %v", err)
486 }
487 route := selected.Route
488 route.ClaimOwnership = owner
489 return reg.Upsert(route)
490 }
491 if err := adapter.Install(ctx, selected, install); err != nil {
492 t.Fatalf("first installation on a provisioned box: %v", err)
493 }
494 row, err := reg.Get(record.Host)
495 if err != nil || row == nil || !row.ClaimOwnership.Valid() || writes != 1 {
496 t.Fatalf("installed row=%+v writes=%d err=%v", row, writes, err)
497 }
498 in, err := inventory.ReadInstallation(ctx, record.Host, obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp)
499 if err != nil || !in.Complete || len(in.Entries) != 1 || in.Entries[0].Kind != "route" || in.Entries[0].Materialization != "materialized" || in.Entries[0].Ownership != row.ClaimOwner {
500 t.Fatalf("installation inventory=%+v err=%v", in, err)
501 }
502 if err := adapter.Install(ctx, selected, install); err != nil {
503 t.Fatalf("repeat installation: %v", err)
504 }
505 again, err := reg.Get(record.Host)
506 if err != nil || again == nil || again.ClaimOwnership != row.ClaimOwnership || writes != 2 {
507 t.Fatalf("repeat changed ownership: row=%+v writes=%d err=%v", again, writes, err)
508 }
509}
510