feat(switcher): ensure shared symlinks on target home after flip (A3)
Wire symlinks.EnsureForAccount into executeSwitch, called immediately after the ~/.claude flip. Guarantees the three shared-state links (session-env, file-history, projects) exist on the target account home even for freshly-provisioned accounts, preventing silent transcript duplication and undo-history divergence on first resume. Best-effort: errors are logged as WARN but never abort the swap. If we returned here the daemon would be left inconsistent (symlink flipped, SetActiveAccount never called). Operator sees the warning in logs and resolves divergent links manually. Tests: - TestFlipReconcilesSharedSymlinksOnTargetHome: empty target home gets all three links pointing at canonical targets after the flip. - TestFlipEnsureSymlinksFailureDoesNotAbortSwap: a planted divergent link triggers the symlinks-package error; the swap completes anyway and the active account is updated. Hermetic: added AccountSwitcher.sharedSymlinks override so tests scope the reconcile inside t.TempDir() and never touch /home/ubuntu/.claude-*-shared. Existing tests migrated to this pattern and hardcoded /tmp/claude-*-xxxx paths replaced with tmpdirs. Phase 1 / Chantier A — task A3.
This commit is contained in:
parent
e16e3526a0
commit
8eaf0bbd35
3 changed files with 204 additions and 4 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package switcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -8,8 +10,19 @@ import (
|
|||
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/quota"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/symlinks"
|
||||
)
|
||||
|
||||
// tmpShared returns a SharedSymlink list whose targets live entirely under
|
||||
// tmpDir, so switcher tests never touch /home/ubuntu/.claude-*-shared.
|
||||
func tmpShared(tmpDir string) []symlinks.SharedSymlink {
|
||||
return []symlinks.SharedSymlink{
|
||||
{Target: filepath.Join(tmpDir, "session-env-shared"), Name: "session-env"},
|
||||
{Target: filepath.Join(tmpDir, "file-history-shared"), Name: "file-history"},
|
||||
{Target: filepath.Join(tmpDir, "projects-shared"), Name: "projects"},
|
||||
}
|
||||
}
|
||||
|
||||
// mockTmux for switcher tests.
|
||||
type mockTmux struct {
|
||||
sessions map[string]bool
|
||||
|
|
@ -143,6 +156,9 @@ func TestKillAndRecreatePoolSessions(t *testing.T) {
|
|||
// touches the real ~/.claude (regression: a reboot used to leave Claude
|
||||
// Code unusable because the test had repointed ~/.claude to /tmp/...).
|
||||
a.homeDir = t.TempDir()
|
||||
// Scope shared-symlink targets to a tmpdir so the post-flip ensure
|
||||
// pass does not write inside /home/ubuntu/.claude-*-shared.
|
||||
a.sharedSymlinks = tmpShared(t.TempDir())
|
||||
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
||||
|
||||
// Active account must have changed.
|
||||
|
|
@ -186,10 +202,12 @@ func TestDedicatedRelaunchAfterSwap(t *testing.T) {
|
|||
s := state.New("")
|
||||
s.SetActiveAccount("compte1")
|
||||
|
||||
home1 := filepath.Join(t.TempDir(), "claude-1-xxxx")
|
||||
home2 := filepath.Join(t.TempDir(), "claude-2-xxxx")
|
||||
cfg := &config.Config{
|
||||
Accounts: []config.AccountConfig{
|
||||
{Name: "compte1", Home: "/tmp/claude-1-xxxx"},
|
||||
{Name: "compte2", Home: "/tmp/claude-2-xxxx"},
|
||||
{Name: "compte1", Home: home1},
|
||||
{Name: "compte2", Home: home2},
|
||||
},
|
||||
Pool: config.PoolConfig{
|
||||
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
|
||||
|
|
@ -199,6 +217,7 @@ func TestDedicatedRelaunchAfterSwap(t *testing.T) {
|
|||
|
||||
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
||||
a.homeDir = t.TempDir()
|
||||
a.sharedSymlinks = tmpShared(t.TempDir())
|
||||
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
||||
|
||||
// The relaunch must send a resume command on the dedicated session,
|
||||
|
|
@ -213,10 +232,121 @@ func TestDedicatedRelaunchAfterSwap(t *testing.T) {
|
|||
if relaunch == "" {
|
||||
t.Fatalf("expected dedicated-1 relaunch send-keys; got %v", tc.sendKeyCalls)
|
||||
}
|
||||
if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR=/tmp/claude-2-xxxx") {
|
||||
if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR="+home2) {
|
||||
t.Errorf("relaunch should set CLAUDE_CONFIG_DIR to target home; got %q", relaunch)
|
||||
}
|
||||
if !strings.Contains(relaunch, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") {
|
||||
t.Errorf("relaunch should include captured UUID; got %q", relaunch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlipReconcilesSharedSymlinksOnTargetHome verifies A3: after the main
|
||||
// ~/.claude flip, the switcher reconciles the three shared-state symlinks
|
||||
// (session-env / file-history / projects) on the TARGET account home.
|
||||
// Scenario: the target home has NO links yet — a freshly-provisioned account
|
||||
// that has never been flipped into. Post-switch, all three links must exist
|
||||
// inside the target home and point at the canonical shared targets.
|
||||
func TestFlipReconcilesSharedSymlinksOnTargetHome(t *testing.T) {
|
||||
tc := newMockTmux()
|
||||
|
||||
s := state.New("")
|
||||
s.SetActiveAccount("compte1")
|
||||
|
||||
// Target home starts empty: EnsureForAccount will mkdir + create links.
|
||||
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
|
||||
cfg := &config.Config{
|
||||
Accounts: []config.AccountConfig{
|
||||
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
|
||||
{Name: "compte2", Home: targetHome},
|
||||
},
|
||||
Pool: config.PoolConfig{
|
||||
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
|
||||
},
|
||||
}
|
||||
|
||||
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
||||
a.homeDir = t.TempDir()
|
||||
shared := tmpShared(t.TempDir())
|
||||
a.sharedSymlinks = shared
|
||||
|
||||
// Pre-assert: no link exists in targetHome.
|
||||
for _, sl := range shared {
|
||||
if _, err := os.Lstat(filepath.Join(targetHome, sl.Name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("pre-condition: %q should not exist yet (err=%v)", sl.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
||||
|
||||
// Post-assert: every required link exists and points at the canonical
|
||||
// target under the tmpdir-scoped shared root.
|
||||
for _, sl := range shared {
|
||||
linkPath := filepath.Join(targetHome, sl.Name)
|
||||
info, err := os.Lstat(linkPath)
|
||||
if err != nil {
|
||||
t.Errorf("expected link at %s after flip: %v", linkPath, err)
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Errorf("%s exists but is not a symlink", linkPath)
|
||||
continue
|
||||
}
|
||||
got, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Errorf("readlink %s: %v", linkPath, err)
|
||||
continue
|
||||
}
|
||||
if got != sl.Target {
|
||||
t.Errorf("link %s points to %q, want %q", linkPath, got, sl.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlipEnsureSymlinksFailureDoesNotAbortSwap verifies A3 best-effort:
|
||||
// if EnsureForAccount returns an error (here: a divergent pre-existing link
|
||||
// that the symlinks package refuses to auto-correct), the flip and the swap
|
||||
// MUST still complete. The shared symlink reconcile is post-flip cleanup,
|
||||
// not a gate on the failover itself — aborting here would leave the daemon
|
||||
// in an inconsistent state (symlink flipped but active account not updated).
|
||||
func TestFlipEnsureSymlinksFailureDoesNotAbortSwap(t *testing.T) {
|
||||
tc := newMockTmux()
|
||||
|
||||
s := state.New("")
|
||||
s.SetActiveAccount("compte1")
|
||||
|
||||
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
|
||||
if err := os.MkdirAll(targetHome, 0700); err != nil {
|
||||
t.Fatalf("mkdir target home: %v", err)
|
||||
}
|
||||
// Plant a divergent link at <targetHome>/session-env. The symlinks
|
||||
// package refuses to auto-correct this (data-loss safeguard) and will
|
||||
// return an error — which the switcher must swallow with a WARN log.
|
||||
bogus := filepath.Join(t.TempDir(), "somewhere-else")
|
||||
if err := os.MkdirAll(bogus, 0700); err != nil {
|
||||
t.Fatalf("mkdir bogus: %v", err)
|
||||
}
|
||||
if err := os.Symlink(bogus, filepath.Join(targetHome, "session-env")); err != nil {
|
||||
t.Fatalf("plant divergent link: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Accounts: []config.AccountConfig{
|
||||
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
|
||||
{Name: "compte2", Home: targetHome},
|
||||
},
|
||||
Pool: config.PoolConfig{
|
||||
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
|
||||
},
|
||||
}
|
||||
|
||||
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
||||
a.homeDir = t.TempDir()
|
||||
a.sharedSymlinks = tmpShared(t.TempDir())
|
||||
|
||||
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
||||
|
||||
// The swap must have completed despite the divergent-link error.
|
||||
if got := s.ActiveAccount(); got != "compte2" {
|
||||
t.Errorf("swap should complete even when ensure fails; active=%q want compte2", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue