package orchestrator import ( "context" "errors" "io" "log" "os" "path/filepath" "strings" "testing" "time" "scm.bstein.dev/bstein/ananke/internal/cluster" "scm.bstein.dev/bstein/ananke/internal/execx" "scm.bstein.dev/bstein/ananke/internal/state" ) // TestHookAccessDeepMatrixReconcileAndSSHAuth runs one orchestration or CLI step. // Signature: TestHookAccessDeepMatrixReconcileAndSSHAuth(t *testing.T). // Why: closes remaining access-gate branch gaps for access validation and SSH // auth wait behavior in the top-level testing module. func TestHookAccessDeepMatrixReconcileAndSSHAuth(t *testing.T) { t.Run("reconcile-access-aggregates-errors", func(t *testing.T) { cfg := lifecycleConfig(t) run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") { return "", errors.New("sudo denied") } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orch, _ := newHookOrchestrator(t, cfg, run, run) err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"}) if err == nil || !strings.Contains(err.Error(), "access validation had") || !strings.Contains(err.Error(), "missing sudo access") { t.Fatalf("expected aggregated reconcile error, got %v", err) } }) t.Run("ssh-auth-denied-fails-fast", func(t *testing.T) { cfg := lifecycleConfig(t) cfg.Startup.RequireNodeSSHAuth = true cfg.Startup.NodeSSHAuthWaitSeconds = 1 cfg.Startup.NodeSSHAuthPollSeconds = 1 run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") { return "Permission denied (publickey)", errors.New("permission denied") } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orch, _ := newHookOrchestrator(t, cfg, run, run) err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"}) if err == nil || !strings.Contains(err.Error(), "ssh auth gate failed") { t.Fatalf("expected ssh auth denied error, got %v", err) } }) t.Run("ssh-auth-timeout-and-context-cancel", func(t *testing.T) { cfg := lifecycleConfig(t) cfg.Startup.RequireNodeSSHAuth = true cfg.Startup.NodeSSHAuthWaitSeconds = 1 cfg.Startup.NodeSSHAuthPollSeconds = 1 run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") { return "no route to host", errors.New("network unreachable") } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orch, _ := newHookOrchestrator(t, cfg, run, run) err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}) if err == nil || !strings.Contains(err.Error(), "node ssh auth gate did not pass within") { t.Fatalf("expected timeout branch, got %v", err) } cancelCtx, cancel := context.WithCancel(context.Background()) cancel() err = orch.TestHookWaitForNodeSSHAuth(cancelCtx, []string{"titan-db"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected canceled context, got %v", err) } }) } // TestHookAccessDeepMatrixLocalRepoAndCache runs one orchestration or CLI step. // Signature: TestHookAccessDeepMatrixLocalRepoAndCache(t *testing.T). // Why: covers local-repo sync and bootstrap-cache error branches that are not // always exercised by lifecycle startup tests. func TestHookAccessDeepMatrixLocalRepoAndCache(t *testing.T) { t.Run("sync-local-repo-input-and-command-errors", func(t *testing.T) { cfg := lifecycleConfig(t) cfg.IACRepoPath = "" orch, _ := newHookOrchestrator(t, cfg, nil, nil) if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil { t.Fatalf("expected empty repo path error") } repo := t.TempDir() cfg = lifecycleConfig(t) cfg.IACRepoPath = repo orch, _ = newHookOrchestrator(t, cfg, nil, nil) if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "is not a git checkout") { t.Fatalf("expected missing .git error, got %v", err) } if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { t.Fatalf("mkdir .git: %v", err) } runStatusErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "git" && strings.Contains(command, "status --porcelain") { return "", errors.New("status failed") } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orch, _ = newHookOrchestrator(t, cfg, runStatusErr, runStatusErr) if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "inspect iac repo working tree") { t.Fatalf("expected status error branch, got %v", err) } runDirty := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "git" && strings.Contains(command, "status --porcelain") { return " M README.md", nil } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orch, _ = newHookOrchestrator(t, cfg, runDirty, runDirty) if err := orch.TestHookSyncLocalIACRepo(context.Background()); err != nil { t.Fatalf("expected dirty-tree skip branch success, got %v", err) } runFetchErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") switch { case name == "git" && strings.Contains(command, "status --porcelain"): return "", nil case name == "git" && strings.Contains(command, "fetch origin --prune"): return "", errors.New("fetch failed") default: return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } } orch, _ = newHookOrchestrator(t, cfg, runFetchErr, runFetchErr) if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "git fetch origin") { t.Fatalf("expected fetch error branch, got %v", err) } }) t.Run("bootstrap-cache-apply-and-refresh-errors", func(t *testing.T) { cfg := lifecycleConfig(t) cfg.LocalBootstrapPaths = []string{"services/bootstrap"} cfg.IACRepoPath = t.TempDir() orch, _ := newHookOrchestrator(t, cfg, nil, nil) if err := orch.TestHookApplyBootstrapCache(context.Background(), "services/bootstrap"); err == nil || !strings.Contains(err.Error(), "bootstrap cache missing") { t.Fatalf("expected missing cache error, got %v", err) } stateFile := filepath.Join(t.TempDir(), "state-file") if err := os.WriteFile(stateFile, []byte("x"), 0o644); err != nil { t.Fatalf("write state-file: %v", err) } cfgFailMkdir := lifecycleConfig(t) cfgFailMkdir.State.Dir = stateFile cfgFailMkdir.LocalBootstrapPaths = []string{"services/bootstrap"} cfgFailMkdir.IACRepoPath = t.TempDir() orchFailMkdir := cluster.New(cfgFailMkdir, &execx.Runner{DryRun: false}, state.New(cfgFailMkdir.State.RunHistoryPath), log.New(io.Discard, "", 0)) orchFailMkdir.SetCommandOverrides(lifecycleDispatcher(&commandRecorder{}), lifecycleDispatcher(&commandRecorder{})) if err := orchFailMkdir.TestHookRefreshBootstrapCache(context.Background()); err == nil || !strings.Contains(err.Error(), "ensure bootstrap cache dir") { t.Fatalf("expected cache-dir creation failure, got %v", err) } runApplyErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { command := name + " " + strings.Join(args, " ") if name == "kubectl" && strings.Contains(command, " apply -f ") { return "", errors.New("apply failed") } return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...) } orchApply, _ := newHookOrchestrator(t, cfg, runApplyErr, runApplyErr) cachePath := orchApply.TestHookBootstrapCachePath("services/bootstrap") if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { t.Fatalf("mkdir cache dir: %v", err) } if err := os.WriteFile(cachePath, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: cached\n"), 0o644); err != nil { t.Fatalf("write cache file: %v", err) } if err := orchApply.TestHookApplyBootstrapCache(context.Background(), "services/bootstrap"); err == nil { t.Fatalf("expected apply cache command failure") } }) }