test(metis): cover service helper branches
This commit is contained in:
parent
59e5c15c38
commit
3566e28936
@ -291,6 +291,14 @@ func TestHelperBranchesAndPersistenceFailures(t *testing.T) {
|
||||
if err := app.StoreSnapshot(SnapshotRecord{}); err == nil {
|
||||
t.Fatal("expected snapshot validation error")
|
||||
}
|
||||
blockedSnapshotParent := filepath.Join(t.TempDir(), "blocked-snapshot-parent")
|
||||
if err := os.WriteFile(blockedSnapshotParent, []byte("block"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.settings.SnapshotsPath = filepath.Join(blockedSnapshotParent, "valid-snapshot.json")
|
||||
if err := app.StoreSnapshot(SnapshotRecord{Node: "titan-15"}); err == nil {
|
||||
t.Fatal("expected snapshot persistence error")
|
||||
}
|
||||
if _, err := app.Build("missing"); err == nil {
|
||||
t.Fatal("expected Build to reject unknown node")
|
||||
}
|
||||
@ -314,11 +322,66 @@ func TestNewAppReportsInventoryErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppReportsDirectoryCreationErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventoryPath := filepath.Join(dir, "inventory.yaml")
|
||||
if err := os.WriteFile(inventoryPath, []byte("classes: []\nnodes: []\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := filepath.Join(dir, "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := Settings{
|
||||
InventoryPath: inventoryPath,
|
||||
CacheDir: filepath.Join(dir, "cache"),
|
||||
ArtifactDir: filepath.Join(dir, "artifacts"),
|
||||
ArtifactStatePath: filepath.Join(dir, "artifacts.json"),
|
||||
HistoryPath: filepath.Join(dir, "history.jsonl"),
|
||||
SnapshotsPath: filepath.Join(dir, "snapshots.json"),
|
||||
TargetsPath: filepath.Join(dir, "targets.json"),
|
||||
}
|
||||
for name, mutate := range map[string]func(*Settings){
|
||||
"cache": func(s *Settings) { s.CacheDir = filepath.Join(blocked, "cache") },
|
||||
"artifact": func(s *Settings) {
|
||||
s.ArtifactDir = filepath.Join(blocked, "artifacts")
|
||||
},
|
||||
"history": func(s *Settings) {
|
||||
s.HistoryPath = filepath.Join(blocked, "history.jsonl")
|
||||
},
|
||||
} {
|
||||
settings := base
|
||||
mutate(&settings)
|
||||
if _, err := NewApp(settings); err == nil {
|
||||
t.Fatalf("expected NewApp %s directory error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 got := (*activeNodeJobError)(nil).Error(); !strings.Contains(got, "active metis job") {
|
||||
t.Fatalf("nil activeNodeJobError = %q", got)
|
||||
}
|
||||
if got := app.activeJobForNode(""); got != nil {
|
||||
t.Fatalf("activeJobForNode empty = %#v", got)
|
||||
}
|
||||
now := time.Now()
|
||||
app.jobs["ignored"] = nil
|
||||
app.jobs["done"] = &Job{ID: "done", Kind: "build", Node: "titan-15", Status: JobDone, StartedAt: now}
|
||||
app.jobs["other-kind"] = &Job{ID: "other-kind", Kind: "watch", Node: "titan-15", Status: JobRunning, StartedAt: now}
|
||||
app.jobs["newer"] = &Job{ID: "newer", Kind: "replace", Node: "titan-15", Status: JobRunning, StartedAt: now}
|
||||
app.jobs["older"] = &Job{ID: "older", Kind: "build", Node: "titan-15", Status: JobQueued, StartedAt: now.Add(-time.Minute)}
|
||||
if got := app.activeJobForNode("titan-15"); got == nil || got.ID != "older" {
|
||||
t.Fatalf("activeJobForNode earliest active = %#v", got)
|
||||
}
|
||||
if _, err := app.reserveJob("build", "titan-15", "", ""); err == nil {
|
||||
t.Fatal("expected reserveJob to reject active node")
|
||||
}
|
||||
app.appendEvent(Event{Kind: "bad", Details: map[string]any{"bad": make(chan int)}})
|
||||
|
||||
if replacementReady(nil, nil) {
|
||||
t.Fatal("replacementReady nil should be false")
|
||||
@ -330,6 +393,17 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) {
|
||||
if got := app.replacementNodes(); len(got) != 0 {
|
||||
t.Fatalf("replacementNodes empty inventory = %#v", got)
|
||||
}
|
||||
app.inventory = &inventory.Inventory{
|
||||
Classes: []inventory.NodeClass{{Name: "ready", Image: "img", Checksum: "sum"}},
|
||||
Nodes: []inventory.NodeSpec{
|
||||
{Name: "z-node", Class: "ready", Hostname: "z-node", IP: "192.168.22.11", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
|
||||
{Name: "a-node", Class: "ready", Hostname: "a-node", IP: "192.168.22.10", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
|
||||
{Name: "missing-class", Class: "missing", Hostname: "missing-class", IP: "192.168.22.12", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
|
||||
},
|
||||
}
|
||||
if got := app.replacementNodes(); len(got) != 2 || got[0].Name != "a-node" || got[1].Name != "z-node" {
|
||||
t.Fatalf("replacementNodes sorted/filtered = %#v", got)
|
||||
}
|
||||
app.settings.FlashHosts = []string{"titan-22"}
|
||||
app.settings.DefaultFlashHost = ""
|
||||
if got := app.flashHosts(); len(got) == 0 {
|
||||
@ -347,6 +421,16 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) {
|
||||
}, &inventory.NodeClass{Image: "img", Checksum: "sum"}) {
|
||||
t.Fatal("replacementReady valid node should be true")
|
||||
}
|
||||
for name, node := range map[string]inventory.NodeSpec{
|
||||
"missing-role": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
|
||||
"missing-url": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "agent", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
|
||||
"missing-token": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
|
||||
"missing-ssh": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", K3sToken: "token"},
|
||||
} {
|
||||
if replacementReady(&node, &inventory.NodeClass{Image: "img", Checksum: "sum"}) {
|
||||
t.Fatalf("replacementReady should reject %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
fileParent := filepath.Join(t.TempDir(), "blocked")
|
||||
if err := os.WriteFile(fileParent, []byte("block"), 0o644); err != nil {
|
||||
@ -357,6 +441,31 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) {
|
||||
if got := app.recentEvents(1); got != nil {
|
||||
t.Fatalf("recentEvents missing file = %#v", got)
|
||||
}
|
||||
historyPath := filepath.Join(t.TempDir(), "history.jsonl")
|
||||
if err := os.WriteFile(historyPath, []byte("{bad-json}\n{\"kind\":\"ok\"}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.settings.HistoryPath = historyPath
|
||||
if got := app.recentEvents(10); len(got) != 1 || got[0].Kind != "ok" {
|
||||
t.Fatalf("recentEvents should skip invalid rows: %#v", got)
|
||||
}
|
||||
app.settings.SnapshotsPath = historyPath
|
||||
if err := app.loadSnapshots(); err == nil {
|
||||
t.Fatal("expected loadSnapshots invalid JSON error")
|
||||
}
|
||||
app.settings.TargetsPath = historyPath
|
||||
if err := app.loadTargets(); err == nil {
|
||||
t.Fatal("expected loadTargets invalid JSON error")
|
||||
}
|
||||
if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "1", "b": "2"}}) {
|
||||
t.Fatal("targetsEqual should reject package length mismatch")
|
||||
}
|
||||
if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "2"}}) {
|
||||
t.Fatal("targetsEqual should reject package value mismatch")
|
||||
}
|
||||
if got := firstLine(" one\ntwo "); got != "one" {
|
||||
t.Fatalf("firstLine = %q", got)
|
||||
}
|
||||
|
||||
kube := fakeKubeServer(t)
|
||||
installKubeFactory(t, kube)
|
||||
|
||||
@ -21,24 +21,47 @@ func TestRemoteHelperBranches(t *testing.T) {
|
||||
if got := ramp(0, 10, 20, 1, 2); got != 1 {
|
||||
t.Fatalf("ramp before start = %v", got)
|
||||
}
|
||||
if got := ramp(0, 1, 1, 2, 3); got != 3 {
|
||||
t.Fatalf("ramp collapsed range = %v", got)
|
||||
}
|
||||
if got := ramp(20, 10, 20, 1, 2); got != 2 {
|
||||
t.Fatalf("ramp after end = %v", got)
|
||||
}
|
||||
if got := mountedHostTmpDir("/tmp/metis-flash-test"); got != "/host-tmp/metis-flash-test" {
|
||||
t.Fatalf("mountedHostTmpDir = %q", got)
|
||||
}
|
||||
if got := mountedHostTmpDir("/var/tmp/metis-flash-test"); got != "/host-tmp/var/tmp/metis-flash-test" {
|
||||
t.Fatalf("mountedHostTmpDir non-tmp = %q", got)
|
||||
}
|
||||
if got := shellQuote(""); got != "''" {
|
||||
t.Fatalf("shellQuote empty = %q", got)
|
||||
}
|
||||
if got := shellQuote("a'b"); got != `'a'"'"'b'` {
|
||||
t.Fatalf("shellQuote = %q", got)
|
||||
}
|
||||
if got, msg := buildStageHeartbeat("n1", "b1", 5*time.Second); got < 8 || !strings.Contains(msg, "Scheduling") {
|
||||
t.Fatalf("buildStageHeartbeat early = %v %q", got, msg)
|
||||
}
|
||||
if got, msg := buildStageHeartbeat("n1", "b1", 400*time.Second); got < 58 || got > 70 || !strings.Contains(msg, "Compressing") {
|
||||
t.Fatalf("buildStageHeartbeat compress = %v %q", got, msg)
|
||||
}
|
||||
if got, msg := flashStageHeartbeat("h1", "artifact", 15*time.Second); got < 88 || !strings.Contains(msg, "Writing") {
|
||||
t.Fatalf("flashStageHeartbeat = %v %q", got, msg)
|
||||
}
|
||||
|
||||
app := newTestApp(t)
|
||||
app.settings.HarborRegistry = "registry.example"
|
||||
app.settings.HarborProject = "metis"
|
||||
app.artifactStore["n1"] = ArtifactSummary{Node: "n1", Ref: "registry.example/metis/n1:latest"}
|
||||
if got := app.remoteArtifactNote("n1"); got != "registry.example/metis/n1:latest" {
|
||||
t.Fatalf("remoteArtifactNote = %q", got)
|
||||
}
|
||||
if got := app.remoteArtifactNote("n2"); got != "registry.example/metis/n2:latest" {
|
||||
t.Fatalf("remoteArtifactNote fallback = %q", got)
|
||||
}
|
||||
if _, err := app.ensureDevice("titan-22", ""); err == nil {
|
||||
t.Fatal("expected empty device selection to fail")
|
||||
}
|
||||
if got := inventoryNodeArch(&inventory.NodeSpec{}, &inventory.NodeClass{Arch: "amd64"}); got != "amd64" {
|
||||
t.Fatalf("inventoryNodeArch = %q", got)
|
||||
}
|
||||
@ -126,3 +149,151 @@ func TestSelectBuilderHostAvoidsBusyBuilderWhenPeersAreFree(t *testing.T) {
|
||||
t.Fatalf("expected titan-05 builder, got %s", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeviceRejectsUnknownCandidate(t *testing.T) {
|
||||
kube := fakeKubeServer(t)
|
||||
installKubeFactory(t, kube)
|
||||
app := newTestApp(t)
|
||||
app.settings.Namespace = "maintenance"
|
||||
app.settings.RunnerImageARM64 = "runner:arm64"
|
||||
|
||||
if _, err := app.ensureDevice("titan-22", "/dev/sda"); err == nil {
|
||||
t.Fatal("expected stale device selection to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBuilderHostScoresStorageAndAMD64(t *testing.T) {
|
||||
kube := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/nodes":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "storage-arm",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "arm64",
|
||||
"hardware": "rpi5",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "free-arm",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "arm64",
|
||||
"hardware": "rpi5",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "desktop",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "amd64",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "titan-24",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "amd64",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/maintenance/pods":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer kube.Close()
|
||||
installKubeFactory(t, kube)
|
||||
app := newTestApp(t)
|
||||
app.settings.Namespace = "maintenance"
|
||||
app.settings.DefaultFlashHost = "desktop"
|
||||
app.inventory.Nodes = []inventory.NodeSpec{
|
||||
{
|
||||
Name: "storage-arm",
|
||||
LonghornDisks: []inventory.LonghornDisk{{Mountpoint: "/var/lib/longhorn"}},
|
||||
},
|
||||
}
|
||||
|
||||
node, err := app.selectBuilderHost("arm64", "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectBuilderHost arm64: %v", err)
|
||||
}
|
||||
if node.Name != "free-arm" {
|
||||
t.Fatalf("expected free-arm to beat storage node, got %s", node.Name)
|
||||
}
|
||||
|
||||
node, err = app.selectBuilderHost("amd64", "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectBuilderHost amd64: %v", err)
|
||||
}
|
||||
if node.Name != "desktop" {
|
||||
t.Fatalf("expected desktop to beat titan-24, got %s", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBuilderHostTieBreaksByName(t *testing.T) {
|
||||
kube := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/nodes":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "beta",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "arm64",
|
||||
"hardware": "rpi5",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "alpha",
|
||||
"labels": map[string]string{
|
||||
"kubernetes.io/arch": "arm64",
|
||||
"hardware": "rpi5",
|
||||
"node-role.kubernetes.io/worker": "true",
|
||||
},
|
||||
},
|
||||
"spec": map[string]any{"unschedulable": false},
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/maintenance/pods":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer kube.Close()
|
||||
installKubeFactory(t, kube)
|
||||
app := newTestApp(t)
|
||||
app.settings.Namespace = "maintenance"
|
||||
app.inventory.Nodes = nil
|
||||
|
||||
node, err := app.selectBuilderHost("arm64", "")
|
||||
if err != nil {
|
||||
t.Fatalf("selectBuilderHost: %v", err)
|
||||
}
|
||||
if node.Name != "alpha" {
|
||||
t.Fatalf("expected alphabetical tie-breaker, got %s", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,6 +316,42 @@ func TestHTTPHandlersExerciseErrorBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlersAdditionalErrorBranches(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
handler := app.Handler()
|
||||
|
||||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||||
if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.settings.SnapshotsPath = filepath.Join(blocked, "snapshots.json")
|
||||
payload := `{"node":"titan-15","snapshot":{"hostname":"titan-15"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload))
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected snapshot persistence error, got %d", resp.Code)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/internal/sentinel/watch", "/api/jobs/build", "/api/jobs/replace", "/api/sentinel/watch"} {
|
||||
req = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Auth-Request-User", "brad")
|
||||
req.Header.Set("X-Auth-Request-Groups", "admin")
|
||||
resp = httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected method-not-allowed for %s, got %d", path, resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Auth-Request-User", "brad")
|
||||
req.Header.Set("X-Auth-Request-Groups", "viewers")
|
||||
if _, ok := app.authorize(req); ok {
|
||||
t.Fatal("authorize should reject non-allowed groups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchHandlersReturnErrorsWhenTargetsCannotPersist(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user