package service import ( "encoding/json" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" "metis/pkg/facts" "metis/pkg/inventory" "metis/pkg/sentinel" ) func TestSettingsHelpersAndSmallUtilities(t *testing.T) { dataDir := filepath.Join(t.TempDir(), "data") t.Setenv("METIS_DATA_DIR", dataDir) t.Setenv("METIS_FLASH_HOSTS", "a, b,, c") t.Setenv("METIS_MAX_DEVICE_BYTES", "12345") t.Setenv("METIS_DEFAULT_FLASH_HOST", "flash-1") t.Setenv("METIS_LOCAL_HOST", "local-1") t.Setenv("METIS_REMOTE_POD_TIMEOUT_SEC", "1800") settings := FromEnv() if got, want := settings.CacheDir, filepath.Join(dataDir, "cache"); got != want { t.Fatalf("CacheDir = %q, want %q", got, want) } if settings.DefaultFlashHost != "flash-1" || settings.LocalHost != "local-1" { t.Fatalf("unexpected env settings: %+v", settings) } if settings.MaxDeviceBytes != 12345 { t.Fatalf("expected MaxDeviceBytes=12345, got %d", settings.MaxDeviceBytes) } if settings.RemotePodTimeout != 1800 { t.Fatalf("expected RemotePodTimeout=1800, got %d", settings.RemotePodTimeout) } if !reflect.DeepEqual(splitList("a, b,, c"), []string{"a", "b", "c"}) { t.Fatalf("splitList mismatch") } if got := getenvInt64("METIS_MAX_DEVICE_BYTES", 1); got != 12345 { t.Fatalf("getenvInt64 = %d", got) } if got := hostnameOr("fallback"); got == "" { t.Fatal("hostnameOr returned empty string") } if got := humanBytes(1536); got != "1.5 KiB" { t.Fatalf("humanBytes = %q", got) } if got := firstLine("alpha\nbeta"); got != "alpha" { t.Fatalf("firstLine = %q", got) } if got := moveToFront([]string{"b", "a", "c"}, "a"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) { t.Fatalf("moveToFront = %#v", got) } if got := errorString(nil); got != "" { t.Fatalf("errorString(nil) = %q", got) } if got := preferredDevice([]Device{{Path: "/dev/sda"}}); got != "/dev/sda" { t.Fatalf("preferredDevice = %q", got) } if got := deviceScore(Device{Name: "mmcblk0", Model: "Micro SD card", Removable: true, Hotplug: true, Transport: "usb"}); got <= 0 { t.Fatalf("expected positive device score, got %d", got) } if got := inventoryNodeArch(nil, nil); got != "arm64" { t.Fatalf("inventoryNodeArch fallback = %q", got) } if got := (&App{settings: Settings{HarborRegistry: "reg/", HarborProject: "/proj/"}}).artifactRepo("node"); got != "reg/proj/node" { t.Fatalf("artifactRepo = %q", got) } } func TestAppJobDeviceAndStateHelpers(t *testing.T) { app := newTestApp(t) app.settings.HistoryPath = filepath.Join(t.TempDir(), "history.jsonl") app.settings.ArtifactStatePath = filepath.Join(t.TempDir(), "artifacts.json") job := app.newJob("build", "titan-15", "titan-22", "/dev/sdz") if job.Status != JobQueued { t.Fatalf("new job status = %s", job.Status) } app.setJob(job.ID, func(j *Job) { j.Status = JobRunning j.Stage = "build" }) if got := app.job(job.ID); got == nil || got.Status != JobRunning { t.Fatalf("setJob did not update job: %#v", got) } app.completeJob(job.ID, func(j *Job) { j.Message = "done" }) if got := app.job(job.ID); got == nil || got.Status != JobDone || got.FinishedAt.IsZero() { t.Fatalf("completeJob did not finish job: %#v", got) } failed := app.newJob("replace", "titan-15", "titan-22", "/dev/sdz") app.failJob(failed.ID, os.ErrNotExist) if got := app.job(failed.ID); got == nil || got.Status != JobError || got.Error == "" { t.Fatalf("failJob did not mark error: %#v", got) } app.appendEvent(Event{Kind: "one", Summary: "first"}) app.appendEvent(Event{Kind: "two", Summary: "second"}) events := app.recentEvents(1) if len(events) != 1 || events[0].Kind != "two" { t.Fatalf("recentEvents returned %#v", events) } app.recordDevices("titan-22", []Device{{Name: "sda", Path: "/dev/sda"}}, nil) devices, err := app.cachedDevices("titan-22") if err != nil || len(devices) != 1 || devices[0].Path != "/dev/sda" { t.Fatalf("cachedDevices = %#v err=%v", devices, err) } devices[0].Path = "/dev/mutated" again, _ := app.cachedDevices("titan-22") if again[0].Path != "/dev/sda" { t.Fatalf("cachedDevices should return a copy, got %#v", again) } app.recordDevices("titan-22", nil, os.ErrPermission) if _, err := app.cachedDevices("titan-22"); err == nil { t.Fatal("expected cached device error") } app.recordDevices("titan-22", []Device{{Path: "/dev/sda"}}, nil) state := app.State("titan-22") if state.SelectedHost != "titan-22" || state.PreferredDevice == "" { t.Fatalf("unexpected state: %+v", state) } } func TestBuildAndReplaceRejectDuplicateActiveNodeJobs(t *testing.T) { app := newTestApp(t) active := app.newJob("replace", "titan-15", "titan-22", "/dev/sdz") if _, err := app.Build("titan-15"); err == nil || !strings.Contains(err.Error(), active.ID) { t.Fatalf("expected build conflict mentioning %s, got %v", active.ID, err) } if _, err := app.Replace("titan-15", "titan-22", "/dev/sdz"); err == nil || !strings.Contains(err.Error(), "active replace job") { t.Fatalf("expected replace conflict, got %v", err) } } func TestAppPersistenceAndTargets(t *testing.T) { dir := t.TempDir() invPath := filepath.Join(dir, "inventory.yaml") if err := os.WriteFile(invPath, []byte(` classes: - name: rpi4 arch: arm64 os: armbian image: file:///tmp/base.img nodes: - name: titan-15 class: rpi4 hostname: titan-15 ip: 192.168.22.43 k3s_role: agent `), 0o644); err != nil { t.Fatal(err) } snapshotsPath := filepath.Join(dir, "snapshots.json") targetsPath := filepath.Join(dir, "targets.json") artifactStatePath := filepath.Join(dir, "artifacts.json") seedSnapshots := map[string]SnapshotRecord{ "titan-15": { Node: "titan-15", CollectedAt: testTime(t), Snapshot: sentinel.Snapshot{Hostname: "titan-15", Kernel: "6.6.63", K3sVersion: "v1.31.5+k3s1"}, }, } data, _ := json.MarshalIndent(seedSnapshots, "", " ") if err := os.WriteFile(snapshotsPath, data, 0o644); err != nil { t.Fatal(err) } seedTargets := map[string]facts.Targets{ "rpi4": {Kernel: "6.6.63"}, } data, _ = json.MarshalIndent(seedTargets, "", " ") if err := os.WriteFile(targetsPath, data, 0o644); err != nil { t.Fatal(err) } seedArtifacts := map[string]ArtifactSummary{ "titan-15": {Node: "titan-15", Ref: "reg/proj/titan-15:latest"}, } data, _ = json.MarshalIndent(seedArtifacts, "", " ") if err := os.WriteFile(artifactStatePath, data, 0o644); err != nil { t.Fatal(err) } app, err := NewApp(Settings{ InventoryPath: invPath, CacheDir: filepath.Join(dir, "cache"), ArtifactDir: filepath.Join(dir, "artifacts"), ArtifactStatePath: artifactStatePath, HistoryPath: filepath.Join(dir, "history.jsonl"), SnapshotsPath: snapshotsPath, TargetsPath: targetsPath, DefaultFlashHost: "titan-22", FlashHosts: []string{"titan-22"}, LocalHost: "titan-22", AllowedGroups: []string{"admin"}, }) if err != nil { t.Fatalf("NewApp: %v", err) } if got := app.artifacts()["titan-15"].Ref; got != "reg/proj/titan-15:latest" { t.Fatalf("artifacts() = %q", got) } if err := app.recordArtifact(ArtifactSummary{Node: "titan-15", Ref: "reg/proj/titan-15:v2"}); err != nil { t.Fatalf("recordArtifact: %v", err) } if err := app.loadArtifacts(); err != nil { t.Fatalf("loadArtifacts: %v", err) } if got := app.artifacts()["titan-15"].Ref; got != "reg/proj/titan-15:v2" { t.Fatalf("recordArtifact/persist mismatch: %q", got) } if err := app.StoreSnapshot(SnapshotRecord{Node: "titan-15", Snapshot: sentinel.Snapshot{Hostname: "titan-15"}}); err != nil { t.Fatalf("StoreSnapshot: %v", err) } if event, err := app.WatchSentinel(); err != nil || event == nil || event.Kind != "sentinel.watch" { t.Fatalf("WatchSentinel: event=%#v err=%v", event, err) } } func TestHelperBranchesAndPersistenceFailures(t *testing.T) { app := newTestApp(t) if got := cachedImageName("/tmp/archive/base.img.xz"); got != "base.img" { t.Fatalf("cachedImageName = %q", got) } if got := humanBytes(1); got != "1 B" { t.Fatalf("humanBytes(1) = %q", got) } if got := humanBytes(1024 * 1024); got != "1.0 MiB" { t.Fatalf("humanBytes(1MiB) = %q", got) } if got := errorString(fmt.Errorf("boom")); got != "boom" { t.Fatalf("errorString = %q", got) } if got := moveToFront([]string{"a", "b", "c"}, "missing"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) { t.Fatalf("moveToFront missing = %#v", got) } if targetsEqual(facts.Targets{Kernel: "a"}, facts.Targets{Kernel: "b"}) { t.Fatal("targetsEqual should reject differing kernels") } if got := deviceScore(Device{Name: "reader", Model: "Card reader", Transport: "usb", Removable: true, Hotplug: true}); got < 75 { t.Fatalf("unexpected deviceScore: %d", got) } if got := cachedImageName("foo.xz"); got != "foo" { t.Fatalf("cachedImageName alias = %q", got) } if got := app.flashHosts(); len(got) == 0 { t.Fatal("flashHosts returned empty list") } app.settings.SnapshotsPath = filepath.Join(t.TempDir(), "missing", "snapshots.json") if err := app.loadSnapshots(); err == nil { t.Fatal("expected loadSnapshots error for missing file") } app.settings.TargetsPath = filepath.Join(t.TempDir(), "missing", "targets.json") if err := app.loadTargets(); err == nil { t.Fatal("expected loadTargets error for missing file") } app.settings.ArtifactStatePath = filepath.Join(t.TempDir(), "missing", "artifacts.json") if err := app.loadArtifacts(); err == nil { t.Fatal("expected loadArtifacts error for missing file") } tmpDir := t.TempDir() app.settings.SnapshotsPath = tmpDir if err := app.persistSnapshots(); err == nil { t.Fatal("expected persistSnapshots error when path is a directory") } app.settings.TargetsPath = tmpDir if err := app.persistTargets(); err == nil { t.Fatal("expected persistTargets error when path is a directory") } app.settings.ArtifactStatePath = tmpDir if err := app.persistArtifacts(); err == nil { t.Fatal("expected persistArtifacts error when path is a directory") } if err := app.StoreSnapshot(SnapshotRecord{}); err == nil { t.Fatal("expected snapshot validation error") } if _, err := app.Build("missing"); err == nil { t.Fatal("expected Build to reject unknown node") } if _, err := app.Replace("missing", "", ""); err == nil { t.Fatal("expected Replace to reject unknown node") } } func TestNewAppReportsInventoryErrors(t *testing.T) { settings := Settings{ InventoryPath: filepath.Join(t.TempDir(), "missing.yaml"), CacheDir: t.TempDir(), ArtifactDir: t.TempDir(), ArtifactStatePath: filepath.Join(t.TempDir(), "artifacts.json"), HistoryPath: filepath.Join(t.TempDir(), "history.jsonl"), SnapshotsPath: filepath.Join(t.TempDir(), "snapshots.json"), TargetsPath: filepath.Join(t.TempDir(), "targets.json"), } if _, err := NewApp(settings); err == nil { t.Fatal("expected NewApp inventory error") } } func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) { app := newTestApp(t) app.setJob("missing", func(*Job) { t.Fatal("setJob should not run for missing job") }) app.completeJob("missing", func(*Job) { t.Fatal("completeJob should not run for missing job") }) app.failJob("missing", os.ErrNotExist) if replacementReady(nil, nil) { t.Fatal("replacementReady nil should be false") } if replacementReady(&inventory.NodeSpec{}, &inventory.NodeClass{}) { t.Fatal("replacementReady empty should be false") } app.inventory = &inventory.Inventory{} if got := app.replacementNodes(); len(got) != 0 { t.Fatalf("replacementNodes empty inventory = %#v", got) } app.settings.FlashHosts = []string{"titan-22"} app.settings.DefaultFlashHost = "" if got := app.flashHosts(); len(got) == 0 { t.Fatal("flashHosts should still include cluster nodes") } if !replacementReady(&inventory.NodeSpec{ Name: "ready", Hostname: "ready", IP: "192.168.22.10", K3sRole: "agent", K3sURL: "https://192.168.22.1:6443", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}, }, &inventory.NodeClass{Image: "img", Checksum: "sum"}) { t.Fatal("replacementReady valid node should be true") } fileParent := filepath.Join(t.TempDir(), "blocked") if err := os.WriteFile(fileParent, []byte("block"), 0o644); err != nil { t.Fatal(err) } app.settings.HistoryPath = filepath.Join(fileParent, "history.jsonl") app.appendEvent(Event{Kind: "noop"}) if got := app.recentEvents(1); got != nil { t.Fatalf("recentEvents missing file = %#v", got) } kube := fakeKubeServer(t) installKubeFactory(t, kube) if err := deleteNodeObjectInCluster("titan-15"); err != nil { t.Fatalf("deleteNodeObjectInCluster success: %v", err) } } func testTime(t *testing.T) time.Time { t.Helper() return time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) }