metis/pkg/service/helpers_test.go

368 lines
12 KiB
Go

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