carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 8e518dd92f6a26862837b65a12b09393c6438462 parent 10a564e
6 files changed, +112 −16
  • internal/host/agent.go +1 −2
  • internal/host/agent_layout_test.go +41 −0
  • internal/host/layoutleg.go +20 −1
  • internal/layout/journal.go +21 −0
  • internal/layout/journal_test.go +28 −0
  • internal/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
}