host: startup reads the layout intent only when a journal is there to recover (plan 4 final review)
With no journal in <data>/layout, recovery runs under "" without opening the deploy store, and the published intent is left for the first serial leg. layout.JournalHosts is the one lister of journals, shared with recover, and it skips the temp files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+112
−16
internal/host/agent.go+1 −2internal/host/agent_layout_test.go+41 −0internal/host/layoutleg.go+20 −1internal/layout/journal.go+21 −0internal/layout/journal_test.go+28 −0internal/layout/pass.go+1 −13
diff --git a/internal/host/agent.go b/internal/host/agent.go| index c3e2a54ca..d1754b1fe 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -383,8 +383,7 @@ func RunAgent(args []string) { |
| }, |
| func(ctx context.Context, swept bool) { |
| pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept) |
| - intentStore, storeErr := openDeployStore(ctx) |
| - intent := startupLayoutIntent(ctx, intentStore, storeErr, metricsBoxLabel(os.Getenv, readMachineID())) |
| + intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID())) |
| recoverLayoutAtStart(ctx, pass, intent) |
| armedLayoutPass.Store(pass) |
| }, |
diff --git a/internal/host/agent_layout_test.go b/internal/host/agent_layout_test.go| index 2e5bb2f46..f07275ee0 100644 |
| --- a/internal/host/agent_layout_test.go |
| +++ b/internal/host/agent_layout_test.go |
| @@ -321,6 +321,47 @@ func TestStartupLayoutIntent(t *testing.T) { |
| } |
| } |
| +// Startup reads the intent only when a journal is there to recover. With |
| +// none, recovery has nothing to do, so it runs under "" without touching |
| +// the deploy store, and the published intent is left for the first serial |
| +// leg to set. |
| +func TestStartupRecoveryIntentReadsOnlyWithAJournal(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + appendIntent(t, s, release.LayoutModeNested, "") |
| + dd := t.TempDir() |
| + opened := 0 |
| + open := func(context.Context) (release.Store, error) { opened++; return s, nil } |
| + |
| + resetLayoutGlobals(t) |
| + if got := startupRecoveryIntent(ctx, dd, open, "kass-1"); got != "" || opened != 0 { |
| + t.Fatalf("no layout directory: intent %q, store opened %d times", got, opened) |
| + } |
| + dir := filepath.Join(dd, "layout") |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, ".h.example.com.tmp-1"), nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := startupRecoveryIntent(ctx, dd, open, "kass-1"); got != "" || opened != 0 { |
| + t.Fatalf("a temp file only: intent %q, store opened %d times", got, opened) |
| + } |
| + if m := layoutIntentSeen.Load(); m != nil { |
| + t.Fatalf("published intent %q with no journal; the first serial leg sets it", *m) |
| + } |
| + |
| + if err := os.WriteFile(provision.LayoutJournalPath(dd, "h.example.com"), []byte("{}"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := startupRecoveryIntent(ctx, dd, open, "kass-1"); got != layout.ModeNested || opened != 1 { |
| + t.Fatalf("with a journal: intent %q, store opened %d times", got, opened) |
| + } |
| + if got := lastLayoutIntent(); got != release.LayoutModeNested { |
| + t.Fatalf("intent seen = %q", got) |
| + } |
| +} |
| + |
| // TestStartupLayoutIntentPublishesUnreadable pins the fix on its own: a box |
| // whose every read fails at startup must report layout.IntentUnreadable, not |
| // "" (none) — recovery already holds a completed revert fenced under exactly |
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go| index edeb82ccb..41071be4e 100644 |
| --- a/internal/host/layoutleg.go |
| +++ b/internal/host/layoutleg.go |
| @@ -174,7 +174,8 @@ func startActivator(ctx context.Context, sweep func(context.Context) error, reco |
| // activator's start waits on. |
| const layoutIntentTimeout = 30 * time.Second |
| -// startupLayoutIntent is the intent startup recovery runs under. It is "" |
| +// startupLayoutIntent reads the intent for startup recovery when there is |
| +// a journal to recover (startupRecoveryIntent). It is "" |
| // when no intent is recorded or none addresses this box. It is |
| // IntentUnreadable when the box cannot be sure: no deploy store, a failed |
| // read, or a request whose mode this binary does not know. Under that |
| @@ -206,6 +207,24 @@ func startupLayoutIntent(ctx context.Context, store release.Store, storeErr erro |
| return layout.Mode(in.Mode) |
| } |
| +// startupRecoveryIntent is the intent startup recovery runs under. The |
| +// intent only matters to a journal, so with none in <data>/layout it is "" |
| +// and the deploy store is not opened: the activator's start does not wait |
| +// on a read it has no use for, and the published intent is left for the |
| +// first serial leg to set. A layout directory that cannot be listed may |
| +// hold journals, so it is read as usual. |
| +func startupRecoveryIntent(ctx context.Context, dataDir string, open func(context.Context) (release.Store, error), boxLabel string) layout.Mode { |
| + hosts, err := layout.JournalHosts(dataDir) |
| + if err == nil && len(hosts) == 0 { |
| + return "" |
| + } |
| + if err != nil { |
| + log.Printf("layout: list journals at start: %v; reading the intent anyway", err) |
| + } |
| + store, err := open(ctx) |
| + return startupLayoutIntent(ctx, store, err, boxLabel) |
| +} |
| + |
| // recoverLayoutAtStart runs the pass's recovery under the startup intent |
| // and logs what it did. |
| func recoverLayoutAtStart(ctx context.Context, pass *layout.Pass, intent layout.Mode) { |
diff --git a/internal/layout/journal.go b/internal/layout/journal.go| index 2c43980af..2068259e8 100644 |
| --- a/internal/layout/journal.go |
| +++ b/internal/layout/journal.go |
| @@ -8,6 +8,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -262,6 +263,26 @@ func RemoveJournal(dataDir, host string) error { |
| return err |
| } |
| +// JournalHosts names every host with a journal in <data>/layout, in the |
| +// directory's order. Dot names are WriteJournal's and UpdatePhase's temp |
| +// files, and are not journals. A directory that is not there holds none. |
| +func JournalHosts(dataDir string) ([]string, error) { |
| + entries, err := os.ReadDir(journalDir(dataDir)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var hosts []string |
| + for _, e := range entries { |
| + if n := e.Name(); !strings.HasPrefix(n, ".") && strings.HasSuffix(n, ".json") { |
| + hosts = append(hosts, strings.TrimSuffix(n, ".json")) |
| + } |
| + } |
| + return hosts, nil |
| +} |
| + |
| // JournalPresent reports whether a journal exists for host, without |
| // reading it. Later tasks use this everywhere a caller needs to know a |
| // host is "fenced" by an in-flight move but has no reason to read the |
diff --git a/internal/layout/journal_test.go b/internal/layout/journal_test.go| index e14980f93..00e5f72cc 100644 |
| --- a/internal/layout/journal_test.go |
| +++ b/internal/layout/journal_test.go |
| @@ -314,3 +314,31 @@ func TestReplaceJournalRefusesAnythingButTheHeldJournal(t *testing.T) { |
| t.Errorf("temp files left: %v", left) |
| } |
| } |
| + |
| +// JournalHosts names every host with a journal, and nothing for the temp |
| +// files WriteJournal and UpdatePhase leave while they work, or for a |
| +// directory that is not there. |
| +func TestJournalHostsIgnoresTempFiles(t *testing.T) { |
| + dd := t.TempDir() |
| + if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { |
| + t.Fatalf("no directory: %v %v", hosts, err) |
| + } |
| + dir := journalDir(dd) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, n := range []string{".h.example.com.tmp-1", ".x.json", "notes.txt"} { |
| + if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { |
| + t.Fatalf("temp files only: %v %v", hosts, err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), []byte("{}"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 1 || hosts[0] != "h.example.com" { |
| + t.Fatalf("one journal: %v %v", hosts, err) |
| + } |
| +} |
diff --git a/internal/layout/pass.go b/internal/layout/pass.go| index e03ba763c..a07fbe43a 100644 |
| --- a/internal/layout/pass.go |
| +++ b/internal/layout/pass.go |
| @@ -4,8 +4,6 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| - "io/fs" |
| - "os" |
| "path/filepath" |
| "sort" |
| "strings" |
| @@ -590,21 +588,11 @@ func (p *Pass) Recover(ctx context.Context, intent Mode) []Result { |
| } |
| func (p *Pass) recover(ctx context.Context, intent Mode, gate func() error) []Result { |
| - entries, err := os.ReadDir(journalDir(p.DataDir)) |
| - if errors.Is(err, fs.ErrNotExist) { |
| - return nil |
| - } |
| + names, err := JournalHosts(p.DataDir) |
| if err != nil { |
| p.logf("layout: recover: read journals: %v", err) |
| return []Result{{Outcome: "refused", Reason: "read journals: " + err.Error()}} |
| } |
| - var names []string |
| - for _, e := range entries { |
| - // Dot names are WriteJournal's and UpdatePhase's temp files. |
| - if n := e.Name(); !strings.HasPrefix(n, ".") && strings.HasSuffix(n, ".json") { |
| - names = append(names, strings.TrimSuffix(n, ".json")) |
| - } |
| - } |
| if len(names) == 0 { |
| return nil |
| } |