layout: extras never take another host's platform files; report nested hosts' flat leftovers (#256 B review)
Ownership of an entry was the longest-matching host among the box's rows. A host deleted or moved away leaves X.env (its admin token), X.config.env, X.db and X-blobs behind, and when X extends this host's name (app.example.com.au beside app.example.com, <host>-staging beside <host>) the shorter host's scan would have swept them into its tree. The scan now reads a host out of every platform-shaped name in the directory (X.env, X.db, X.db-wal, X.db-shm, X.config.env, X-sidecar.env, X.db.corrupt, .activator-X.*) and treats those hosts as owners alongside the rows, so X's platform files and the app entries beside them are left where they are, and reported with "no row on this box" when X has none. A test plants every shape for a dotted and a dashed extension. A nested host's flat leftovers (the hosts migrated before the pass carried app entries along, the 104 on IE) are reported on every pass in the log and the host's status note, platform-named strays included, and never moved. The link walk over an app's directory logs before it starts, since it runs twice while the host is stopped. docs/site/cli.md says that a revert does not bring back what the app wrote relative to its new working directory (it stays in the instance directory, not lost), and that files named like another instance's are never taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+314
−18
docs/site/cli.md+1 −1internal/layout/extras.go+143 −17internal/layout/extras_pass_linux_test.go+44 −0internal/layout/extras_test.go+94 −0internal/layout/pass.go+18 −0internal/layout/plan.go+10 −0internal/layout/preflight.go+4 −0
CI passed — run details
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 1b2da95f3..44dc7d488 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1897,7 +1897,7 @@ Every instance the activator runs used to live in one shared directory as one us |
| `layout migrate` is refused while any box in scope runs a platform release from before the layout, so a fleet is rolled forward first and migrated second. |
| -What moves with an instance is everything in the box's data directory that carries its hostname: its database and the platform's files beside it, and anything the app made for itself under that name, such as a `<host>/` directory holding its keys or a `<host>-blobs` store. They land in the instance's directory under the same names, which is where an app that derives paths from its database path now looks. An entry that cannot be moved safely, such as a file hard-linked from outside the instance's own files, or a symlink, stops the move with its name in the reason, and the instance is held until it is cleared. A directory that carries no hostname, such as a `blobs` directory that several apps wrote into with the working directory `/`, is not moved: the box cannot tell whose it is. The agent log names those once a move has happened; an operator moves what an app owns into that app's instance directory by hand, and it comes along on any later revert or migration. `revert` brings the same names back. |
| +What moves with an instance is everything in the box's data directory that carries its hostname: its database and the platform's files beside it, and anything the app made for itself under that name, such as a `<host>/` directory holding its keys or a `<host>-blobs` store. They land in the instance's directory under the same names, which is where an app that derives paths from its database path now looks. An entry that cannot be moved safely, such as a file hard-linked from outside the instance's own files, or a symlink, stops the move with its name in the reason, and the instance is held until it is cleared. A directory that carries no hostname, such as a `blobs` directory that several apps wrote into with the working directory `/`, is not moved: the box cannot tell whose it is. The agent log names those once a move has happened; an operator moves what an app owns into that app's instance directory by hand, and it comes along on any later revert or migration. A file named like another instance's platform files (`other.example.com.env`, `other.example.com.db`), including one left by an instance that has since been deleted or moved away, is never taken; the log names it. `revert` brings the same names back. What an app wrote relative to its working directory while nested (a `data/blobs` under its instance directory) is not among them: it stays in the instance's directory, where the flat app does not look, but nothing is lost. An instance that was moved before the platform carried entries along still has them in the shared directory; `carlos status --layout` and the agent log say so on every pass until an operator moves them. |
| You rarely need `migrate` at all. With no intent recorded, a box uses the nested layout by default as soon as its isolation is ready (the probe passes), and moves its instances on its own, with the same hour of grace for an awake one. `carlos status --layout` marks such a box `(nested by default)`, and says why a box the default has not reached is still flat. To keep box-by-box control, record `migrate --box` or `revert` first; a recorded intent always wins. A deployment that is not ready for this at all puts `CARLOS_LAYOUT_DEFAULT=flat` in each box's host.env. |
diff --git a/internal/layout/extras.go b/internal/layout/extras.go| index 38f924488..5fef52ff6 100644 |
| --- a/internal/layout/extras.go |
| +++ b/internal/layout/extras.go |
| @@ -7,6 +7,8 @@ import ( |
| "path/filepath" |
| "sort" |
| "strings" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| ) |
| // The entries an app makes for itself (discussion #256). |
| @@ -88,6 +90,115 @@ func fixedNames(p *Plan) map[string]bool { |
| return names |
| } |
| +// fixedSuffixes are the platform's per-host file shapes under the flat |
| +// layout, X plus suffix (flatFiles), longest first so ".db.corrupt" is |
| +// read before ".db" and ".config.env" before ".env". |
| +var fixedSuffixes = []string{".db.corrupt", ".config.env", "-sidecar.env", ".db-wal", ".db-shm", ".db", ".env"} |
| + |
| +// fixedShapeHost reads the host X out of a name that has one of the |
| +// platform's fixed per-host shapes (X.db, X.env, X.config.env, |
| +// X-sidecar.env, X.db-wal, X.db-shm, X.db.corrupt, .activator-X.*), and |
| +// reports whether it has one. X must be a valid host shape with a dot in |
| +// it (every host the platform serves has one; registry.db and |
| +// litestream.env are the box's, not a host's); a name that is not |
| +// host-shaped under any suffix is nobody's platform file. |
| +func fixedShapeHost(name string) (string, bool) { |
| + hostLike := func(x string) bool { return strings.Contains(x, ".") && controlname.ValidHostShape(x) } |
| + if rest, ok := strings.CutPrefix(name, ".activator-"); ok { |
| + if x, ok := fixedShapeHost(rest); ok { |
| + return x, true |
| + } |
| + if i := strings.Index(rest, ".db-"); i > 0 && hostLike(rest[:i]) { |
| + return rest[:i], true |
| + } |
| + return "", false |
| + } |
| + for _, suffix := range fixedSuffixes { |
| + if x, ok := strings.CutSuffix(name, suffix); ok && x != "" { |
| + if hostLike(x) { |
| + return x, true |
| + } |
| + return "", false |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +// knownHosts is every host an entry can belong to: the box's rows, and |
| +// every host the directory's own platform-named files are evidence of. A |
| +// host deleted from the box, or moved away, leaves X.env (its admin |
| +// token), X.config.env, X-blobs and the rest behind under its name, and |
| +// when X extends this host's name (app.example.com.au beside |
| +// app.example.com; <host>-staging beside <host>) a scan that knew only |
| +// the rows would give them to the shorter host. Its files say X existed, |
| +// so X owns what its rules match, exactly as a row would. |
| +func knownHosts(entries []os.DirEntry, rows []string) []string { |
| + seen := map[string]bool{} |
| + var hosts []string |
| + for _, h := range rows { |
| + if !seen[h] { |
| + seen[h] = true |
| + hosts = append(hosts, h) |
| + } |
| + } |
| + for _, e := range entries { |
| + if x, ok := fixedShapeHost(e.Name()); ok && !seen[x] { |
| + seen[x] = true |
| + hosts = append(hosts, x) |
| + } |
| + } |
| + return hosts |
| +} |
| + |
| +// hostEntries sorts a directory's entries that match host's rules into |
| +// the ones host owns (fixed platform names of host itself left out when |
| +// skipFixed) and the ones a longer known host owns, by owner. |
| +func hostEntries(entries []os.DirEntry, host string, known []string, skipFixed map[string]bool) (ours []string, others map[string]string) { |
| + others = map[string]string{} |
| + for _, e := range entries { |
| + name := e.Name() |
| + if skipFixed[name] || !belongsTo(name, host) { |
| + continue |
| + } |
| + if owner := ownerOf(name, known); owner != host { |
| + others[name] = owner |
| + continue |
| + } |
| + ours = append(ours, name) |
| + } |
| + sort.Strings(ours) |
| + return ours, others |
| +} |
| + |
| +// reportOthers says, once per scan, what matched the host's rules but |
| +// belongs to a longer host, and whether that host still has a row: the |
| +// operator reads which files a departed host left in the shared |
| +// directory. Sorted by name so a log reads the same way twice. |
| +func reportOthers(p *Plan, dir string, others map[string]string, rows []string) { |
| + if len(others) == 0 { |
| + return |
| + } |
| + hasRow := map[string]bool{} |
| + for _, h := range rows { |
| + hasRow[h] = true |
| + } |
| + names := make([]string, 0, len(others)) |
| + for name := range others { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + var parts []string |
| + for _, name := range names { |
| + owner := others[name] |
| + if hasRow[owner] { |
| + parts = append(parts, fmt.Sprintf("%s (%s's)", name, owner)) |
| + } else { |
| + parts = append(parts, fmt.Sprintf("%s (%s's, which has no row on this box)", name, owner)) |
| + } |
| + } |
| + p.logf("layout: %s: left in %s, named for another host: %s", p.Host, dir, strings.Join(parts, ", ")) |
| +} |
| + |
| // extraNameOK is the rule a recorded or scanned extra name must pass: one |
| // path element, not one of the plan's own names, and host's by belongsTo. |
| // It is what keeps a journal from being an instruction (design §3.3): a |
| @@ -156,13 +267,14 @@ func (p *Plan) ExtraNames() []string { |
| // ScanExtras lists the entries in the plan's source directory that belong |
| // to the host and that the plan does not already name, sorted. hosts is |
| -// every host with a row on the box; an entry a longer host's rule also |
| -// matches is that host's and is left alone. An entry whose destination is |
| -// already taken is a refusal naming both, never a choice: on a migration |
| -// the destination check has just cleared the instance directory of |
| -// everything the plan did not name, so a taken destination was planted |
| -// since (a hold, like any planted destination); on a revert it is a flat |
| -// file the pass will not overwrite. |
| +// every host with a row on the box; with the hosts the directory's own |
| +// platform files are evidence of (knownHosts), an entry a longer host's |
| +// rule also matches is that host's, is left alone, and is reported. An |
| +// entry whose destination is already taken is a refusal naming both, |
| +// never a choice: on a migration the destination check has just cleared |
| +// the instance directory of everything the plan did not name, so a taken |
| +// destination was planted since (a hold, like any planted destination); |
| +// on a revert it is a flat file the pass will not overwrite. |
| // |
| // The scan runs under the fence, with the host stopped, so the listing is |
| // the whole of what the app made; and inside the serialized section with |
| @@ -176,14 +288,10 @@ func ScanExtras(p *Plan, hosts []string) ([]string, error) { |
| if err != nil { |
| return nil, fmt.Errorf("layout: %s: list %s: %w", p.Host, src, err) |
| } |
| - fixed := fixedNames(p) |
| - all := append([]string{p.Host}, hosts...) |
| - var names []string |
| - for _, e := range entries { |
| - name := e.Name() |
| - if fixed[name] || !belongsTo(name, p.Host) || ownerOf(name, all) != p.Host { |
| - continue |
| - } |
| + rows := append([]string{p.Host}, hosts...) |
| + names, others := hostEntries(entries, p.Host, knownHosts(entries, rows), fixedNames(p)) |
| + reportOthers(p, src, others, rows) |
| + for _, name := range names { |
| taken, err := lstatPresent(filepath.Join(dst, name)) |
| if err != nil { |
| return nil, fmt.Errorf("layout: %s: %w", p.Host, err) |
| @@ -191,12 +299,28 @@ func ScanExtras(p *Plan, hosts []string) ([]string, error) { |
| if taken { |
| return nil, fmt.Errorf("layout: %s: %s exists at both %s and %s; refusing to choose", p.Host, name, filepath.Join(src, name), filepath.Join(dst, name)) |
| } |
| - names = append(names, name) |
| } |
| - sort.Strings(names) |
| return names, nil |
| } |
| +// FlatLeftovers lists what a host that is already nested still has in |
| +// the data directory under its own name: the entries a move made before |
| +// #256 left behind, platform-named ones included (a <host>.db there |
| +// beside the nested one is the thing an operator most wants to know |
| +// about). Ownership is judged as the scan judges it. Nothing is moved; |
| +// the pass reports these on every pass for a nested host (convergeHost). |
| +func FlatLeftovers(dataDir, host string, hosts []string) []string { |
| + entries, err := os.ReadDir(dataDir) |
| + if err != nil { |
| + return nil |
| + } |
| + ours, _ := hostEntries(entries, host, knownHosts(entries, append([]string{host}, hosts...)), nil) |
| + for i, name := range ours { |
| + ours[i] = filepath.Join(dataDir, name) |
| + } |
| + return ours |
| +} |
| + |
| // platformNames are the entries the platform itself keeps directly under |
| // the data directory. UnattributedEntries leaves them out of its report. |
| var platformNames = map[string]bool{ |
| @@ -218,6 +342,8 @@ func UnattributedEntries(dataDir string, hosts []string) []string { |
| if err != nil { |
| return nil |
| } |
| + // Rows only, not knownHosts: a departed host's files are exactly what |
| + // this report is for, and "no host on this box" is true of them. |
| var out []string |
| for _, e := range entries { |
| name := e.Name() |
diff --git a/internal/layout/extras_pass_linux_test.go b/internal/layout/extras_pass_linux_test.go| index efe3a80b3..56ce10c82 100644 |
| --- a/internal/layout/extras_pass_linux_test.go |
| +++ b/internal/layout/extras_pass_linux_test.go |
| @@ -101,6 +101,9 @@ func TestConvergeMovesAppCreatedEntriesWithTheHost(t *testing.T) { |
| if strings.Contains(joined, filepath.Join(p.DataDir, decoy)) { |
| t.Errorf("the decoy host's own directory was reported as unowned:\n%s", joined) |
| } |
| + if !strings.Contains(joined, "named for another host: "+decoy+" ("+decoy+"'s), "+decoy+"-blobs ("+decoy+"'s)") { |
| + t.Errorf("the decoy host's entries were not reported as left for it:\n%s", joined) |
| + } |
| // Revert brings the same names back, and only those. |
| res = byHost(t, p.Converge(context.Background(), ModeFlat)) |
| @@ -281,3 +284,44 @@ func TestRecoverRefusesAJournalNamingAnotherHostsEntry(t *testing.T) { |
| t.Fatal("the refused journal was removed") |
| } |
| } |
| + |
| +// Review (#256 B): the hosts migrated before the pass carried the app's |
| +// entries along (the 104 on IE) still have them in the data directory, |
| +// where the namespace cannot see them. The pass never moves a nested |
| +// host's files, but it says so on every pass, in the log and in the |
| +// host's status note, until an operator has moved them. |
| +func TestConvergeReportsFlatLeftoversOfANestedHost(t *testing.T) { |
| + p, _ := box(t) |
| + if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" { |
| + t.Fatalf("setup: %+v", res[host]) |
| + } |
| + // What an earlier release left behind: the app's directory, and a |
| + // platform-named file minted at the flat path since. |
| + seedAppEntries(t, p.DataDir, host) |
| + if err := os.WriteFile(filepath.Join(p.DataDir, host+".env"), []byte("CARLOS_ADMIN_TOKEN=old\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var logged []string |
| + p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } |
| + res := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + if res[host].Outcome != "unchanged" { |
| + t.Fatalf("%+v", res[host]) |
| + } |
| + want := "still in " + p.DataDir + " from before its move, where the instance cannot see them: " + |
| + filepath.Join(p.DataDir, host) + ", " + filepath.Join(p.DataDir, host+"-blobs") + ", " + filepath.Join(p.DataDir, host+".cache") + ", " + filepath.Join(p.DataDir, host+".env") + |
| + ". move what the app needs into " + rawInstanceDir(p.DataDir, host) + " by hand" |
| + if j := strings.Join(logged, "\n"); !strings.Contains(j, "layout: "+host+": "+want) { |
| + t.Errorf("log:\n%s\nwant a line with:\n%s", j, want) |
| + } |
| + if n := p.Note(host); n != want { |
| + t.Errorf("note = %q", n) |
| + } |
| + for _, rel := range []string{filepath.Join(host, "instance.key"), host + ".env"} { |
| + if _, err := os.Stat(filepath.Join(p.DataDir, rel)); err != nil { |
| + t.Errorf("a report moved %s: %v", rel, err) |
| + } |
| + } |
| + if n := p.Note(replica); strings.Contains(n, "still in") { |
| + t.Errorf("the replica, with nothing left behind, got the note: %q", n) |
| + } |
| +} |
diff --git a/internal/layout/extras_test.go b/internal/layout/extras_test.go| index 7bb43fd2b..42b105af4 100644 |
| --- a/internal/layout/extras_test.go |
| +++ b/internal/layout/extras_test.go |
| @@ -3,6 +3,7 @@ |
| package layout |
| import ( |
| + "fmt" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -98,3 +99,96 @@ func TestUnattributedEntriesSkipsHostsAndPlatformNames(t *testing.T) { |
| t.Fatalf("UnattributedEntries = %v, want %v", got, want) |
| } |
| } |
| + |
| +// Review (#256 B): ownership knows more than the rows. A host deleted or |
| +// moved away leaves its platform-named files behind, and when its name |
| +// extends this host's, the shorter host's scan must not sweep them in, |
| +// nor the app entries beside them that the files are evidence of. Each |
| +// platform shape, for a dotted and a dashed extension, with no row for |
| +// either: none is an extra, and each is reported with "no row". |
| +func TestScanExtrasLeavesAnotherHostsPlatformFilesAlone(t *testing.T) { |
| + dd, rd := t.TempDir(), t.TempDir() |
| + shapes := func(x string) []string { |
| + return []string{x + ".env", x + ".db", x + ".db-wal", x + ".db-shm", x + ".config.env", x + "-sidecar.env", ".activator-" + x + ".db-litestream", x + ".db.corrupt"} |
| + } |
| + var planted []string |
| + for _, x := range []string{host + ".au", host + "-staging"} { |
| + planted = append(planted, shapes(x)...) |
| + // The app entries of that host, which only its files evidence. |
| + planted = append(planted, x, x+"-blobs") |
| + } |
| + planted = append(planted, host, host+"-blobs") |
| + for _, name := range planted { |
| + if err := os.MkdirAll(filepath.Join(dd, name), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, mode := range []Mode{ModeNested, ModeFlat} { |
| + p, err := PlanFor(dd, rd, host, mode) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var logged []string |
| + p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } |
| + if mode == ModeFlat { |
| + // The revert scans the instance directory; stage the same |
| + // names there to show the rule holds in that direction too. |
| + raw := rawInstanceDir(dd, host) |
| + for _, name := range planted { |
| + if err := os.MkdirAll(filepath.Join(raw, name), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + os.RemoveAll(filepath.Join(dd, host)) |
| + os.RemoveAll(filepath.Join(dd, host+"-blobs")) |
| + } |
| + got, err := ScanExtras(p, []string{replica}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Join(got, " ") != host+" "+host+"-blobs" { |
| + t.Errorf("%s: ScanExtras = %v, want only the host's own two entries", mode, got) |
| + } |
| + joined := strings.Join(logged, "\n") |
| + for _, x := range []string{host + ".au", host + "-staging"} { |
| + for _, name := range append(shapes(x), x, x+"-blobs") { |
| + if !strings.Contains(joined, name+" ("+x+"'s, which has no row on this box)") { |
| + t.Errorf("%s: %s not reported as %s's with no row:\n%s", mode, name, x, joined) |
| + } |
| + } |
| + } |
| + } |
| + // And a host that is a row: reported as its own, without the "no row". |
| + p, _ := PlanFor(dd, rd, host, ModeNested) |
| + var logged []string |
| + p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } |
| + if _, err := ScanExtras(p, []string{host + ".au"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if j := strings.Join(logged, "\n"); !strings.Contains(j, host+".au.env ("+host+".au's)") || strings.Contains(j, host+".au.env ("+host+".au's, which") { |
| + t.Errorf("a host with a row is reported wrongly:\n%s", j) |
| + } |
| +} |
| + |
| +func TestFixedShapeHostReadsEveryPlatformShape(t *testing.T) { |
| + for name, want := range map[string]string{ |
| + "a.example.com.env": "a.example.com", |
| + "a.example.com.db": "a.example.com", |
| + "a.example.com.db-wal": "a.example.com", |
| + "a.example.com.db-shm": "a.example.com", |
| + "a.example.com.config.env": "a.example.com", |
| + "a.example.com-sidecar.env": "a.example.com", |
| + "a.example.com.db.corrupt": "a.example.com", |
| + ".activator-a.example.com.db-litestream": "a.example.com", |
| + "a.example.com": "", |
| + "a.example.com-blobs": "", |
| + "litestream.env": "", |
| + "registry.db": "", |
| + ".env": "", |
| + } { |
| + got, ok := fixedShapeHost(name) |
| + if got != want || ok != (want != "") { |
| + t.Errorf("fixedShapeHost(%q) = %q %v, want %q", name, got, ok, want) |
| + } |
| + } |
| +} |
diff --git a/internal/layout/pass.go b/internal/layout/pass.go| index 6c9514e83..26a3870cd 100644 |
| --- a/internal/layout/pass.go |
| +++ b/internal/layout/pass.go |
| @@ -296,6 +296,17 @@ func (p *Pass) convergeHost(ctx context.Context, rt registry.Route, routes []reg |
| case activator.LayoutNested: |
| if mode == ModeNested { |
| p.forgetAwake(rt.Host) |
| + // A host moved before the pass took the app's own entries |
| + // with it (#256) has them still in the data directory, |
| + // where its namespace cannot see them. Nothing moves here: |
| + // the host is running, and what the app has minted since |
| + // may be the copy it wants. Said on every pass, so an |
| + // operator reading status or the log sees it while it lasts. |
| + if left := FlatLeftovers(p.DataDir, rt.Host, hostsOf(routes)); len(left) > 0 { |
| + text := fmt.Sprintf("still in %s from before its move, where the instance cannot see them: %s. move what the app needs into %s by hand", p.DataDir, strings.Join(left, ", "), rawInstanceDir(p.DataDir, rt.Host)) |
| + p.logf("layout: %s: %s", rt.Host, text) |
| + p.note(rt.Host, text) |
| + } |
| return Result{rt.Host, "unchanged", ""} |
| } |
| case activator.LayoutFlat: |
| @@ -330,6 +341,7 @@ func (p *Pass) migrateHost(ctx context.Context, host string, routes []registry.R |
| if err != nil { |
| return Result{host, "refused", err.Error()} |
| } |
| + plan.Logf = p.logf |
| if err := Preflight(plan, routes); err != nil { |
| return Result{host, "refused", err.Error()} |
| } |
| @@ -549,6 +561,8 @@ func (p *Pass) recheck(host string, routes []registry.Route) (reason string, pla |
| if err != nil { |
| return err.Error(), false |
| } |
| + plan.Logf = p.logf |
| + |
| if err := Preflight(plan, routes); err != nil { |
| return err.Error(), false |
| } |
| @@ -756,6 +770,8 @@ func (p *Pass) shift(host string, mode Mode, prior *Journal) (res Result, h hand |
| if err != nil { |
| return Result{host, "refused", err.Error()}, handoverNone, false |
| } |
| + plan.Logf = p.logf |
| + |
| if err := Preflight(plan, routes); err != nil { |
| return Result{host, "refused", err.Error()}, handoverNone, false |
| } |
| @@ -1094,6 +1110,8 @@ func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]r |
| if err != nil { |
| return Result{host, "refused", notActing(err)} |
| } |
| + plan.Logf = p.logf |
| + |
| hosts := make([]string, 0, len(byHost)) |
| for h := range byHost { |
| hosts = append(hosts, h) |
diff --git a/internal/layout/plan.go b/internal/layout/plan.go| index 35988def1..ed1b17194 100644 |
| --- a/internal/layout/plan.go |
| +++ b/internal/layout/plan.go |
| @@ -73,6 +73,16 @@ type Plan struct { |
| // the plan can find the pass's own directory under it (the aside |
| // directory, outside any instance tree) without being handed it again. |
| DataDir string |
| + // Logf is the pass's logger, set by the pass after PlanFor so the |
| + // checks that run on the plan (the extras scan, the link walk) can |
| + // say what they are doing and what they left. Nil logs nothing. |
| + Logf func(string, ...any) |
| +} |
| + |
| +func (p *Plan) logf(format string, a ...any) { |
| + if p.Logf != nil { |
| + p.Logf(format, a...) |
| + } |
| } |
| // artifactNames is the fixed order of the nine artifacts the pass tracks. |
diff --git a/internal/layout/preflight.go b/internal/layout/preflight.go| index 3d118c5d6..da96d6d25 100644 |
| --- a/internal/layout/preflight.go |
| +++ b/internal/layout/preflight.go |
| @@ -291,6 +291,10 @@ func LinkCheck(p *Plan, at func(Artifact) string) error { |
| continue |
| } |
| if a.Extra && fi.IsDir() { |
| + // Said before the walk, which stats every file: a blob store |
| + // can be large, the host is stopped meanwhile, and the walk |
| + // runs twice (sources, then destinations). |
| + p.logf("layout: %s: checking every file under %s for hard links and symlinks before the move goes on", p.Host, path) |
| if err := walkExtraLinks(p.Host, p.DataDir, path); err != nil { |
| return err |
| } |