diff --git a/tools/thothctl/internal/config/installation.go b/tools/thothctl/internal/config/installation.go index c843ee5c..8ac424ec 100644 --- a/tools/thothctl/internal/config/installation.go +++ b/tools/thothctl/internal/config/installation.go @@ -246,6 +246,10 @@ func (i Installation) UpdateStatePath() string { return filepath.Join(i.ControlDirectory(), "update-state.json") } +func (i Installation) RestartStatePath() string { + return filepath.Join(i.ControlDirectory(), "restart-state.json") +} + // ProjectName is stable for one installation and avoids collisions between different checkouts. func (i Installation) ProjectName() string { sum := sha256.Sum256([]byte(i.Path)) diff --git a/tools/thothctl/internal/config/installation_test.go b/tools/thothctl/internal/config/installation_test.go index d1844dee..1ac4f34f 100644 --- a/tools/thothctl/internal/config/installation_test.go +++ b/tools/thothctl/internal/config/installation_test.go @@ -224,6 +224,9 @@ func TestInstallationControlPathsAreIsolatedForDescriptorsSharingOneCheckout(t * if filepath.Dir(installation.UpdateStatePath()) != filepath.Dir(installation.CurrentImageOverridePath()) { t.Fatalf("state %q and selector %q do not share one installation control directory", installation.UpdateStatePath(), installation.CurrentImageOverridePath()) } + if got, want := installation.RestartStatePath(), filepath.Join(installation.ControlDirectory(), "restart-state.json"); got != want { + t.Fatalf("RestartStatePath() = %q, want %q", got, want) + } } } diff --git a/tools/thothctl/internal/pi/state.go b/tools/thothctl/internal/pi/state.go index 20759ab4..827c34ce 100644 --- a/tools/thothctl/internal/pi/state.go +++ b/tools/thothctl/internal/pi/state.go @@ -180,13 +180,17 @@ type updateLock struct { metadata string } -var ErrLockHeld = errors.New("another Pi update or rollback is already in progress") +var ErrLockHeld = errors.New("another Pi update, restart, or rollback is already in progress") + +func lifecycleLockPath(statePath string) string { + return filepath.Join(filepath.Dir(statePath), "pi-lifecycle.lock") +} func acquireLock(statePath string) (*updateLock, error) { if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil { return nil, errors.New("could not create Pi update recovery directory") } - path := statePath + ".lock" + path := lifecycleLockPath(statePath) file := flock.New(path, flock.SetPermissions(0o600)) locked, err := file.TryLock() if err != nil { diff --git a/tools/thothctl/internal/pi/state_test.go b/tools/thothctl/internal/pi/state_test.go index 027d2b96..772b3dc9 100644 --- a/tools/thothctl/internal/pi/state_test.go +++ b/tools/thothctl/internal/pi/state_test.go @@ -24,6 +24,20 @@ func TestAdvisoryLockRejectsAConcurrentOwner(t *testing.T) { } } +func TestUpdateAndRestartStatePathsShareOneLifecycleLock(t *testing.T) { + dir := t.TempDir() + first, err := acquireLock(filepath.Join(dir, "update-state.json")) + if err != nil { + t.Fatal(err) + } + defer first.Release() + + second, err := acquireLock(filepath.Join(dir, "restart-state.json")) + if !errors.Is(err, ErrLockHeld) || second != nil { + t.Fatalf("second lock = %#v, %v; want nil, ErrLockHeld", second, err) + } +} + func TestAdvisoryLockCrashReleasesAndReacquires(t *testing.T) { statePath := filepath.Join(t.TempDir(), "update-state.json") if os.Getenv("THOTHCTL_LOCK_CRASH_HELPER") == "1" { @@ -47,10 +61,11 @@ func TestAdvisoryLockCrashReleasesAndReacquires(t *testing.T) { func TestAdvisoryLockIgnoresPartialDiagnosticMetadata(t *testing.T) { statePath := filepath.Join(t.TempDir(), "update-state.json") - if err := os.WriteFile(statePath+".lock", nil, 0o600); err != nil { + lockPath := lifecycleLockPath(statePath) + if err := os.WriteFile(lockPath, nil, 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(statePath+".lock.owner.json", []byte("{partial"), 0o600); err != nil { + if err := os.WriteFile(lockPath+".owner.json", []byte("{partial"), 0o600); err != nil { t.Fatal(err) } lock, err := acquireLock(statePath)