243 lines
8.0 KiB
Go
243 lines
8.0 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"scm.bstein.dev/bstein/ananke/internal/config"
|
||
|
|
"scm.bstein.dev/bstein/ananke/internal/metrics"
|
||
|
|
"scm.bstein.dev/bstein/ananke/internal/ups"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestDaemonRunMetricsEnabledBranchError runs one orchestration or CLI step.
|
||
|
|
// Signature: TestDaemonRunMetricsEnabledBranchError(t *testing.T).
|
||
|
|
// Why: covers Run path where metrics are enabled and startup fails before the ticker loop.
|
||
|
|
func TestDaemonRunMetricsEnabledBranchError(t *testing.T) {
|
||
|
|
stateDir := t.TempDir()
|
||
|
|
orch := newDaemonTestOrchestrator(t, stateDir)
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
UPS: config.UPS{
|
||
|
|
Enabled: true,
|
||
|
|
PollSeconds: 1,
|
||
|
|
RuntimeSafetyFactor: 1.0,
|
||
|
|
DebounceCount: 1,
|
||
|
|
},
|
||
|
|
Metrics: config.Metrics{
|
||
|
|
Enabled: true,
|
||
|
|
BindAddr: "",
|
||
|
|
Path: "/metrics",
|
||
|
|
},
|
||
|
|
State: config.State{
|
||
|
|
IntentPath: filepath.Join(stateDir, "intent.json"),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
orch: orch,
|
||
|
|
targets: []Target{
|
||
|
|
{
|
||
|
|
Name: "Pyrphoros",
|
||
|
|
Target: "pyrphoros@localhost",
|
||
|
|
Provider: &daemonFakeProvider{
|
||
|
|
samples: []ups.Sample{{OnBattery: false, RuntimeSeconds: 10000, RawStatus: "OL"}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
exporter: metrics.New(),
|
||
|
|
}
|
||
|
|
err := d.Run(context.Background())
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "metrics.bind_addr must not be empty") {
|
||
|
|
t.Fatalf("expected metrics bind error from Run metrics-enabled path, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestDaemonRunProviderErrorContinueBranch runs one orchestration or CLI step.
|
||
|
|
// Signature: TestDaemonRunProviderErrorContinueBranch(t *testing.T).
|
||
|
|
// Why: covers provider-read error continue branch where telemetry timeout does not trigger.
|
||
|
|
func TestDaemonRunProviderErrorContinueBranch(t *testing.T) {
|
||
|
|
stateDir := t.TempDir()
|
||
|
|
orch := newDaemonTestOrchestrator(t, stateDir)
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
UPS: config.UPS{
|
||
|
|
Enabled: true,
|
||
|
|
PollSeconds: 1,
|
||
|
|
RuntimeSafetyFactor: 1.0,
|
||
|
|
DebounceCount: 3,
|
||
|
|
TelemetryTimeoutSeconds: 120,
|
||
|
|
},
|
||
|
|
State: config.State{
|
||
|
|
IntentPath: filepath.Join(stateDir, "intent.json"),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
orch: orch,
|
||
|
|
targets: []Target{
|
||
|
|
{
|
||
|
|
Name: "Statera",
|
||
|
|
Target: "statera@localhost",
|
||
|
|
Provider: &daemonFakeProvider{
|
||
|
|
errs: []error{context.DeadlineExceeded, context.DeadlineExceeded, context.DeadlineExceeded},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
exporter: metrics.New(),
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
|
||
|
|
defer cancel()
|
||
|
|
if err := d.Run(ctx); err == nil {
|
||
|
|
t.Fatalf("expected context cancellation while exercising provider-error continue path")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTriggerShutdownForwardedIntentWriteFailureWarningBranch runs one orchestration or CLI step.
|
||
|
|
// Signature: TestTriggerShutdownForwardedIntentWriteFailureWarningBranch(t *testing.T).
|
||
|
|
// Why: covers triggerShutdown branch where forwarded completion intent write fails but trigger still succeeds.
|
||
|
|
func TestTriggerShutdownForwardedIntentWriteFailureWarningBranch(t *testing.T) {
|
||
|
|
tmp := t.TempDir()
|
||
|
|
sshPath := filepath.Join(tmp, "ssh")
|
||
|
|
if err := os.WriteFile(sshPath, []byte("#!/usr/bin/env bash\nset -euo pipefail\necho forwarded\n"), 0o755); err != nil {
|
||
|
|
t.Fatalf("write fake ssh: %v", err)
|
||
|
|
}
|
||
|
|
t.Setenv("PATH", tmp+":"+os.Getenv("PATH"))
|
||
|
|
|
||
|
|
intentPath := filepath.Join(tmp, "intent-dir")
|
||
|
|
if err := os.MkdirAll(intentPath, 0o755); err != nil {
|
||
|
|
t.Fatalf("mkdir intent dir: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
SSHUser: "atlas",
|
||
|
|
SSHPort: 2277,
|
||
|
|
State: config.State{
|
||
|
|
IntentPath: intentPath,
|
||
|
|
},
|
||
|
|
Coordination: config.Coordination{
|
||
|
|
ForwardShutdownHost: "titan-db",
|
||
|
|
ForwardShutdownConfig: "/etc/ananke/ananke.yaml",
|
||
|
|
CommandTimeoutSeconds: 3,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
exporter: metrics.New(),
|
||
|
|
}
|
||
|
|
if err := d.triggerShutdown(context.Background(), "forwarded-intent-write-fail"); err != nil {
|
||
|
|
t.Fatalf("expected forwarded shutdown to succeed even when completion intent write fails, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTriggerShutdownLocalIntentWriteFailureWarningBranch runs one orchestration or CLI step.
|
||
|
|
// Signature: TestTriggerShutdownLocalIntentWriteFailureWarningBranch(t *testing.T).
|
||
|
|
// Why: covers triggerShutdown local completion intent warning branch when write fails after successful shutdown.
|
||
|
|
func TestTriggerShutdownLocalIntentWriteFailureWarningBranch(t *testing.T) {
|
||
|
|
stateDir := t.TempDir()
|
||
|
|
orch := newDaemonTestOrchestrator(t, stateDir)
|
||
|
|
|
||
|
|
intentPath := filepath.Join(stateDir, "intent-dir")
|
||
|
|
if err := os.MkdirAll(intentPath, 0o755); err != nil {
|
||
|
|
t.Fatalf("mkdir intent dir: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
State: config.State{
|
||
|
|
IntentPath: intentPath,
|
||
|
|
},
|
||
|
|
Shutdown: config.Shutdown{
|
||
|
|
EmergencySkipDrain: true,
|
||
|
|
EmergencySkipEtcd: true,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
orch: orch,
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
exporter: metrics.New(),
|
||
|
|
}
|
||
|
|
if err := d.triggerShutdown(context.Background(), "local-intent-write-fail"); err != nil {
|
||
|
|
t.Fatalf("expected local shutdown success even when completion intent write fails, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestForwardShutdownDefaultTimeoutBranch runs one orchestration or CLI step.
|
||
|
|
// Signature: TestForwardShutdownDefaultTimeoutBranch(t *testing.T).
|
||
|
|
// Why: covers forwardShutdown default command-timeout branch when timeout config is unset.
|
||
|
|
func TestForwardShutdownDefaultTimeoutBranch(t *testing.T) {
|
||
|
|
tmp := t.TempDir()
|
||
|
|
sshPath := filepath.Join(tmp, "ssh")
|
||
|
|
if err := os.WriteFile(sshPath, []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n"), 0o755); err != nil {
|
||
|
|
t.Fatalf("write fake ssh: %v", err)
|
||
|
|
}
|
||
|
|
t.Setenv("PATH", tmp+":"+os.Getenv("PATH"))
|
||
|
|
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
SSHUser: "atlas",
|
||
|
|
Coordination: config.Coordination{
|
||
|
|
ForwardShutdownHost: "titan-db",
|
||
|
|
ForwardShutdownConfig: "/etc/ananke/ananke.yaml",
|
||
|
|
CommandTimeoutSeconds: 0,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
}
|
||
|
|
if err := d.forwardShutdown(context.Background(), "default-timeout-branch"); err != nil {
|
||
|
|
t.Fatalf("expected forward shutdown with default timeout to succeed, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestForwardShutdownKnownHostsRepairIncludesJumpHost runs one orchestration or CLI step.
|
||
|
|
// Signature: TestForwardShutdownKnownHostsRepairIncludesJumpHost(t *testing.T).
|
||
|
|
// Why: covers known-host repair host-list branch that appends the configured SSH jump host.
|
||
|
|
func TestForwardShutdownKnownHostsRepairIncludesJumpHost(t *testing.T) {
|
||
|
|
tmp := t.TempDir()
|
||
|
|
attemptMarker := filepath.Join(tmp, "attempt")
|
||
|
|
sshPath := filepath.Join(tmp, "ssh")
|
||
|
|
script := `#!/usr/bin/env bash
|
||
|
|
set -euo pipefail
|
||
|
|
marker="` + attemptMarker + `"
|
||
|
|
if [[ ! -f "$marker" ]]; then
|
||
|
|
echo "REMOTE HOST IDENTIFICATION HAS CHANGED!" >&2
|
||
|
|
touch "$marker"
|
||
|
|
exit 255
|
||
|
|
fi
|
||
|
|
echo "forwarded"
|
||
|
|
`
|
||
|
|
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
|
||
|
|
t.Fatalf("write fake ssh: %v", err)
|
||
|
|
}
|
||
|
|
sshKeygenPath := filepath.Join(tmp, "ssh-keygen")
|
||
|
|
if err := os.WriteFile(sshKeygenPath, []byte("#!/usr/bin/env bash\nset -euo pipefail\nexit 0\n"), 0o755); err != nil {
|
||
|
|
t.Fatalf("write fake ssh-keygen: %v", err)
|
||
|
|
}
|
||
|
|
sshKeyscanPath := filepath.Join(tmp, "ssh-keyscan")
|
||
|
|
if err := os.WriteFile(sshKeyscanPath, []byte("#!/usr/bin/env bash\nset -euo pipefail\necho fake-key\n"), 0o755); err != nil {
|
||
|
|
t.Fatalf("write fake ssh-keyscan: %v", err)
|
||
|
|
}
|
||
|
|
t.Setenv("PATH", tmp+":"+os.Getenv("PATH"))
|
||
|
|
|
||
|
|
d := &Daemon{
|
||
|
|
cfg: config.Config{
|
||
|
|
SSHUser: "atlas",
|
||
|
|
SSHPort: 2277,
|
||
|
|
SSHConfigFile: filepath.Join(tmp, "ssh-config"),
|
||
|
|
SSHIdentityFile: filepath.Join(tmp, "id_ed25519"),
|
||
|
|
SSHJumpHost: "titan-jh",
|
||
|
|
Coordination: config.Coordination{
|
||
|
|
ForwardShutdownHost: "titan-db",
|
||
|
|
ForwardShutdownConfig: "/etc/ananke/ananke.yaml",
|
||
|
|
CommandTimeoutSeconds: 3,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
log: log.New(io.Discard, "", 0),
|
||
|
|
}
|
||
|
|
if err := d.forwardShutdown(context.Background(), "repair-includes-jump-host"); err != nil {
|
||
|
|
t.Fatalf("expected forward shutdown to recover after known-host repair with jump host, got %v", err)
|
||
|
|
}
|
||
|
|
}
|