Files
orca/internal/drift/drift_test.go
T
Jon Chery 03f3585f16 feat(P10b): drift detection (R-018/R-019/R-020, REQ-103..113)
internal/drift/drift.go: Detector (Watch via iter.Seq2, Aggregate,
Remediate with cooldown-on-success, Acknowledge), Config with tiered
cadence (critical 5s + Path units, standard 30s, default 60s).
internal/cli/drift.go: orca drift {show,watch,acknowledge,remediate,
config}. internal/emitter/drift_path.go: systemd Path+service unit
emitter (User=orca, ProtectSystem=strict). scripts/orca-drift-notify.sh
(sha256 event JSON), orca-remediate.sh (cooldown-on-success, transient
retry). Pre-flight gate (R-020, --force + per-ns scoping). orca
system user (REQ-111), NFS detection (D-233), orca job restart for
EnvironmentFile drift (D-235).

---ci---
project: orca
phase: 10b
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:17:41 +00:00

466 lines
14 KiB
Go

package drift
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
type mockTransport struct {
execOut []byte
execErr error
execFn func(ctx context.Context, peer string, cmd string) ([]byte, error)
readOut []byte
readErr error
writes []writeEntry
}
type writeEntry struct {
peer string
path string
content []byte
mode os.FileMode
}
func (m *mockTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
if m.execFn != nil {
return m.execFn(ctx, peer, cmd)
}
return m.execOut, m.execErr
}
func (m *mockTransport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
m.writes = append(m.writes, writeEntry{peer, path, content, mode})
return true, nil
}
func (m *mockTransport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) {
return m.readOut, m.readErr
}
func setupDriftTestEnv(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_LEAD_STATE_DIR", dir)
return dir
}
func TestParseEvent(t *testing.T) {
raw := []byte(`{
"event_id":"EVT-1",
"ts":"2026-01-01T00:00:00Z",
"host":"peer1",
"path":"/etc/traefik/dynamic/orca.yml",
"status":"modified",
"new_sha256":"abc",
"latest_txn":"T-1234567890abcdef",
"expected_sha256":"def",
"drift_confirmed":true,
"action":"reported",
"action_result":"skipped",
"operator_id":"op1"
}`)
e, err := ParseEvent(raw)
if err != nil {
t.Fatalf("ParseEvent: %v", err)
}
if e.EventID != "EVT-1" {
t.Errorf("EventID = %q, want EVT-1", e.EventID)
}
if e.Host != "peer1" {
t.Errorf("Host = %q, want peer1", e.Host)
}
if e.Path != "/etc/traefik/dynamic/orca.yml" {
t.Errorf("Path = %q", e.Path)
}
if e.Status != StatusModified {
t.Errorf("Status = %q, want modified", e.Status)
}
if !e.DriftConfirmed {
t.Errorf("DriftConfirmed = false, want true")
}
if e.Action != ActionReported {
t.Errorf("Action = %q, want reported", e.Action)
}
if e.ActionResult != ActionResultSkipped {
t.Errorf("ActionResult = %q, want skipped", e.ActionResult)
}
}
func TestParseEventInvalid(t *testing.T) {
_, err := ParseEvent([]byte(`{not json`))
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestMarshalEventRoundTrip(t *testing.T) {
e := Event{EventID: "E1", Host: "h", Path: "/p", Status: StatusCreated, DriftConfirmed: true}
raw, err := MarshalEvent(e)
if err != nil {
t.Fatalf("MarshalEvent: %v", err)
}
out, err := ParseEvent(raw)
if err != nil {
t.Fatalf("ParseEvent: %v", err)
}
if out.EventID != e.EventID || out.Host != e.Host || out.Path != e.Path || out.Status != e.Status {
t.Errorf("round-trip mismatch: %+v vs %+v", out, e)
}
}
func TestLoadConfigDefault(t *testing.T) {
cfg, err := LoadConfig("")
if err != nil {
t.Fatalf("LoadConfig empty: %v", err)
}
if !cfg.Polling.Enabled {
t.Errorf("Polling.Enabled = false, want true")
}
if cfg.Polling.DefaultInterval != 60*time.Second {
t.Errorf("DefaultInterval = %v, want 60s", cfg.Polling.DefaultInterval)
}
if len(cfg.Paths.Critical) == 0 {
t.Errorf("Critical paths empty")
}
found := false
for _, c := range cfg.Paths.Critical {
if c.Pattern == "/etc/traefik/dynamic/orca.yml" {
found = true
if c.Interval != 5*time.Second {
t.Errorf("critical interval = %v, want 5s", c.Interval)
}
if !c.SystemdPathUnit {
t.Errorf("SystemdPathUnit = false, want true")
}
}
}
if !found {
t.Errorf("traefik critical path missing")
}
found = false
for _, s := range cfg.Paths.Standard {
if s.Pattern == "/etc/orca/actual/*" {
found = true
if s.Interval != 30*time.Second {
t.Errorf("standard interval = %v, want 30s", s.Interval)
}
}
}
if !found {
t.Errorf("/etc/orca/actual/* standard path missing")
}
if !cfg.Remediate.Auto {
t.Errorf("Remediate.Auto = false, want true")
}
}
func TestLoadConfigFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "drift.json")
cfgJSON := `{
"polling": {"enabled": true, "default_interval": 90000000000, "max_concurrent_peers": 4},
"paths": {
"critical": [{"tier":"critical","pattern":"/etc/critical.yml","interval":5000000000,"systemd_path_unit":true}],
"standard": [{"tier":"standard","pattern":"/etc/standard.yml","interval":30000000000}],
"excluded": ["/run/orca/*"]
},
"remediate": {"auto": false, "auto_paths": [], "require_approval": ["/etc/critical.yml"], "notify_on_remediation": false}
}`
if err := os.WriteFile(path, []byte(cfgJSON), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Polling.MaxConcurrentPeers != 4 {
t.Errorf("MaxConcurrentPeers = %d, want 4", cfg.Polling.MaxConcurrentPeers)
}
if cfg.Polling.DefaultInterval != 90*time.Second {
t.Errorf("DefaultInterval = %v, want 90s", cfg.Polling.DefaultInterval)
}
if cfg.Remediate.Auto {
t.Errorf("Auto = true, want false")
}
if len(cfg.Paths.Critical) != 1 || cfg.Paths.Critical[0].Pattern != "/etc/critical.yml" {
t.Errorf("critical = %+v", cfg.Paths.Critical)
}
}
func TestValidateConfigValid(t *testing.T) {
cfg := DefaultConfig()
if err := ValidateConfig(cfg); err != nil {
t.Fatalf("ValidateConfig(default): %v", err)
}
}
func TestValidateConfigEmptyPattern(t *testing.T) {
cfg := DefaultConfig()
cfg.Paths.Critical[0].Pattern = ""
if err := ValidateConfig(cfg); err == nil {
t.Fatal("expected error for empty critical pattern")
}
}
func TestValidateConfigPollingZero(t *testing.T) {
cfg := DefaultConfig()
cfg.Polling.Enabled = true
cfg.Polling.DefaultInterval = 0
if err := ValidateConfig(cfg); err == nil {
t.Fatal("expected error for zero default_interval with polling enabled")
}
}
func TestValidateConfigOverlapCriticalExcluded(t *testing.T) {
cfg := DefaultConfig()
cfg.Paths.Excluded = append(cfg.Paths.Excluded, "/etc/traefik/dynamic/orca.yml")
err := ValidateConfig(cfg)
if err == nil {
t.Fatal("expected overlap error")
}
if !errors.Is(err, ErrOverlapCritical) {
t.Errorf("expected ErrOverlapCritical, got %v", err)
}
}
func TestValidateConfigNil(t *testing.T) {
if err := ValidateConfig(nil); err == nil {
t.Fatal("expected error for nil config")
}
}
func TestAggregateLocalMissing(t *testing.T) {
setupDriftTestEnv(t)
d := NewDefaultDetector(&mockTransport{})
events, err := d.Aggregate(context.Background(), "")
if err != nil {
t.Fatalf("Aggregate missing: %v", err)
}
if events != nil {
t.Errorf("events = %v, want nil", events)
}
}
func TestAggregateLocal(t *testing.T) {
setupDriftTestEnv(t)
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
t.Fatalf("write aggregated: %v", err)
}
d := NewDefaultDetector(&mockTransport{})
events, err := d.Aggregate(context.Background(), "")
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
if len(events) != 1 {
t.Fatalf("events len = %d, want 1", len(events))
}
if events[0].EventID != "E1" {
t.Errorf("EventID = %q, want E1", events[0].EventID)
}
}
func TestAggregateRemote(t *testing.T) {
setupDriftTestEnv(t)
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E2","host":"p","path":"/etc/orca/actual/ns-a/x","status":"created","drift_confirmed":true}]}`
mt := &mockTransport{readOut: []byte(doc)}
d := NewDefaultDetector(mt)
events, err := d.Aggregate(context.Background(), "lead:22")
if err != nil {
t.Fatalf("Aggregate remote: %v", err)
}
if len(events) != 1 {
t.Fatalf("events len = %d, want 1", len(events))
}
if events[0].EventID != "E2" {
t.Errorf("EventID = %q, want E2", events[0].EventID)
}
}
func TestWatchCancels(t *testing.T) {
setupDriftTestEnv(t)
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
t.Fatalf("write aggregated: %v", err)
}
d := NewDefaultDetector(&mockTransport{})
d.SetCooldown(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
gotEvents := 0
for e, err := range d.Watch(ctx, []PathSpec{{Pattern: "/etc/traefik/dynamic/orca.yml", Interval: 10 * time.Millisecond}}) {
if err != nil {
t.Fatalf("Watch err: %v", err)
}
gotEvents++
_ = e
cancel()
break
}
if gotEvents != 1 {
t.Errorf("gotEvents = %d, want 1", gotEvents)
}
}
func TestWatchStreamsAndDedupes(t *testing.T) {
setupDriftTestEnv(t)
doc := `{"ts":"2026-01-01T00:00:00Z","events":[{"event_id":"E1","host":"p","path":"/etc/traefik/dynamic/orca.yml","status":"modified","drift_confirmed":true}]}`
if err := os.WriteFile(AggregatedPath(), []byte(doc), 0o644); err != nil {
t.Fatalf("write aggregated: %v", err)
}
d := NewDefaultDetector(&mockTransport{})
d.SetCooldown(50 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
got := 0
for _, err := range d.Watch(ctx, nil) {
if err != nil {
t.Fatalf("Watch err: %v", err)
}
got++
}
if got != 1 {
t.Errorf("got = %d, want 1 (dedup)", got)
}
}
func TestRemediateCooldownOnSuccess(t *testing.T) {
setupDriftTestEnv(t)
mt := &mockTransport{execOut: []byte("ok")}
d := NewDefaultDetector(mt)
d.SetCooldown(1 * time.Hour)
if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err != nil {
t.Fatalf("first Remediate: %v", err)
}
if err := d.Remediate(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml", false); err == nil {
t.Fatal("second Remediate should hit cooldown")
} else if !errors.Is(err, ErrCooldown) {
t.Errorf("expected ErrCooldown, got %v", err)
}
}
func TestRemediateForceBypassesCooldown(t *testing.T) {
setupDriftTestEnv(t)
mt := &mockTransport{execOut: []byte("ok")}
d := NewDefaultDetector(mt)
d.SetCooldown(1 * time.Hour)
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err != nil {
t.Fatalf("first: %v", err)
}
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", true); err != nil {
t.Fatalf("force bypass: %v", err)
}
}
func TestRemediateTransientNoCooldown(t *testing.T) {
setupDriftTestEnv(t)
mt := &mockTransport{execErr: fmt.Errorf("connection refused")}
d := NewDefaultDetector(mt)
d.SetCooldown(1 * time.Hour)
if err := d.Remediate(context.Background(), "lead:22", "/etc/p", false); err == nil {
t.Fatal("expected transient error")
}
if d.inCooldown("/etc/p") {
t.Errorf("cooldown entered after transient failure")
}
}
func TestRemediateRequiresLead(t *testing.T) {
setupDriftTestEnv(t)
d := NewDefaultDetector(&mockTransport{})
if err := d.Remediate(context.Background(), "", "/etc/p", true); err == nil {
t.Fatal("expected error for empty lead")
}
}
func TestAcknowledgeWrites(t *testing.T) {
setupDriftTestEnv(t)
mt := &mockTransport{}
d := NewDefaultDetector(mt)
if err := d.Acknowledge(context.Background(), "lead:22", "/etc/traefik/dynamic/orca.yml"); err != nil {
t.Fatalf("Acknowledge: %v", err)
}
if len(mt.writes) != 1 {
t.Fatalf("writes = %d, want 1", len(mt.writes))
}
w := mt.writes[0]
if w.peer != "lead:22" {
t.Errorf("peer = %q, want lead:22", w.peer)
}
if w.path != AcknowledgmentsPath() {
t.Errorf("path = %q, want %q", w.path, AcknowledgmentsPath())
}
}
func TestAcknowledgeRequiresLead(t *testing.T) {
setupDriftTestEnv(t)
d := NewDefaultDetector(&mockTransport{})
if err := d.Acknowledge(context.Background(), "", "/etc/p"); err == nil {
t.Fatal("expected error for empty lead")
}
}
func TestNamespaceScopeClusterWide(t *testing.T) {
events := []Event{
{EventID: "E1", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true},
{EventID: "E2", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true},
{EventID: "E3", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true, Action: ActionAcknowledged},
}
out := NamespaceScope(events, "")
if len(out) != 2 {
t.Errorf("cluster-wide scope len = %d, want 2 (ack excluded)", len(out))
}
}
func TestNamespaceScopePerNamespace(t *testing.T) {
events := []Event{
{EventID: "E1", Path: "/etc/orca/actual/ns-a/x", DriftConfirmed: true},
{EventID: "E2", Path: "/etc/orca/actual/ns-b/y", DriftConfirmed: true},
{EventID: "E3", Path: "/etc/traefik/dynamic/orca.yml", DriftConfirmed: true},
}
nsA := NamespaceScope(events, "ns-a")
if len(nsA) != 1 || nsA[0].EventID != "E1" {
t.Errorf("ns-a scope = %+v, want only E1", nsA)
}
nsB := NamespaceScope(events, "ns-b")
if len(nsB) != 1 || nsB[0].EventID != "E2" {
t.Errorf("ns-b scope = %+v, want only E2", nsB)
}
}
func TestExpandBraces(t *testing.T) {
out := expandBraces("/etc/x.{service,timer}")
if len(out) != 2 {
t.Fatalf("len = %d, want 2", len(out))
}
if out[0] != "/etc/x.service" || out[1] != "/etc/x.timer" {
t.Errorf("out = %v", out)
}
}
func TestPathGlobMatch(t *testing.T) {
if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a") {
t.Errorf("expected glob match for single segment")
}
if !pathGlobMatch("/etc/orca/actual/*", "/etc/orca/actual/ns-a/x") {
t.Errorf("expected glob match for nested segment (drift /* crosses /)")
}
if !pathGlobMatch("/etc/systemd/system/orca-collector.{service,timer}", "/etc/systemd/system/orca-collector.service") {
t.Errorf("expected brace match")
}
}
func TestNsForPath(t *testing.T) {
if got := nsForPath("/etc/orca/actual/ns-a/x"); got != "ns-a" {
t.Errorf("nsForPath = %q, want ns-a", got)
}
if got := nsForPath("/etc/traefik/dynamic/orca.yml"); got != "" {
t.Errorf("nsForPath = %q, want empty", got)
}
}