ananke/testing/orchestrator/hooks_access_deep_matrix_test.go

195 lines
8.6 KiB
Go
Raw Normal View History

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")
}
})
}