258 lines
9.1 KiB
Go
258 lines
9.1 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"metis/pkg/facts"
|
|
"metis/pkg/inventory"
|
|
)
|
|
|
|
func TestServiceArtifactAndSnapshotPersistenceErrorBranches(t *testing.T) {
|
|
app := newTestApp(t)
|
|
|
|
fileParent := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(fileParent, []byte("block"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.settings.ArtifactStatePath = filepath.Join(fileParent, "artifacts.json")
|
|
if err := app.persistArtifacts(); err == nil {
|
|
t.Fatal("expected persistArtifacts to fail when parent is a file")
|
|
}
|
|
|
|
app.settings.SnapshotsPath = filepath.Join(fileParent, "snapshots.json")
|
|
if err := app.persistSnapshots(); err == nil {
|
|
t.Fatal("expected persistSnapshots to fail when parent is a file")
|
|
}
|
|
|
|
app.settings.TargetsPath = filepath.Join(fileParent, "targets.json")
|
|
if err := app.persistTargets(); err == nil {
|
|
t.Fatal("expected persistTargets to fail when parent is a file")
|
|
}
|
|
}
|
|
|
|
func TestServiceReplacementAndDeviceBranches(t *testing.T) {
|
|
app := newTestApp(t)
|
|
ready := inventory.NodeSpec{
|
|
Name: "ready",
|
|
Class: "rpi4",
|
|
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"},
|
|
}
|
|
incomplete := inventory.NodeSpec{Name: "incomplete", Class: "rpi4"}
|
|
class := inventory.NodeClass{Name: "rpi4", Image: "file:///tmp/base.img", Checksum: "sha256:abc"}
|
|
app.inventory = &inventory.Inventory{Classes: []inventory.NodeClass{class}, Nodes: []inventory.NodeSpec{ready, incomplete}}
|
|
if got := app.replacementNodes(); len(got) != 1 || got[0].Name != "ready" {
|
|
t.Fatalf("replacementNodes = %#v", got)
|
|
}
|
|
if err := app.ensureReplacementReady("incomplete"); err == nil {
|
|
t.Fatal("expected ensureReplacementReady to reject incomplete node")
|
|
}
|
|
if diff := diffTargets(map[string]facts.Targets{"a": {Kernel: "1"}}, map[string]facts.Targets{"a": {Kernel: "2"}, "b": {Kernel: "3"}}); len(diff) != 2 {
|
|
t.Fatalf("diffTargets = %#v", diff)
|
|
}
|
|
|
|
app.recordDevices("host", []Device{{Path: "/dev/sda"}}, nil)
|
|
if got, err := app.cachedDevices("host"); err != nil || len(got) != 1 {
|
|
t.Fatalf("cachedDevices = %#v err=%v", got, err)
|
|
}
|
|
app.recordDevices("host", nil, errors.New("boom"))
|
|
if got, err := app.cachedDevices("host"); err == nil || len(got) != 1 {
|
|
t.Fatalf("cachedDevices error snapshot = %#v err=%v", got, err)
|
|
}
|
|
if _, err := app.Replace("incomplete", "titan-22", "/dev/sdz"); err == nil {
|
|
t.Fatal("expected Replace to reject incomplete node")
|
|
}
|
|
}
|
|
|
|
func TestServiceHarborBranches(t *testing.T) {
|
|
harbor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v2.0/projects"):
|
|
_, _ = w.Write([]byte(`[]`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v2.0/projects":
|
|
w.WriteHeader(http.StatusCreated)
|
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/artifacts"):
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
|
{"digest": "sha256:aaa", "push_time": "2026-04-01T10:00:00Z"},
|
|
{"digest": "sha256:bbb", "push_time": "2026-04-01T09:00:00Z"},
|
|
})
|
|
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/artifacts/"):
|
|
w.WriteHeader(http.StatusAccepted)
|
|
default:
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}
|
|
}))
|
|
defer harbor.Close()
|
|
|
|
app := &App{settings: Settings{
|
|
HarborAPIBase: harbor.URL + "/api/v2.0",
|
|
HarborUsername: "admin",
|
|
HarborPassword: "pw",
|
|
HarborProject: "metis",
|
|
HarborRegistry: "registry.example",
|
|
}}
|
|
if got := app.artifactRepo("node"); got != "registry.example/metis/node" {
|
|
t.Fatalf("artifactRepo = %q", got)
|
|
}
|
|
if err := app.ensureHarborProject(); err != nil {
|
|
t.Fatalf("ensureHarborProject create: %v", err)
|
|
}
|
|
if err := app.pruneHarborArtifacts("node", 1); err != nil {
|
|
t.Fatalf("pruneHarborArtifacts: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceHarborErrorBranches(t *testing.T) {
|
|
harbor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v2.0/projects"):
|
|
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/artifacts"):
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
|
{"digest": "sha256:aaa", "push_time": "2026-04-01T10:00:00Z"},
|
|
{"digest": "sha256:bbb", "push_time": "2026-04-01T09:00:00Z"},
|
|
})
|
|
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/artifacts/"):
|
|
http.Error(w, "delete failed", http.StatusInternalServerError)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer harbor.Close()
|
|
|
|
app := &App{settings: Settings{
|
|
HarborAPIBase: harbor.URL + "/api/v2.0",
|
|
HarborUsername: "admin",
|
|
HarborPassword: "pw",
|
|
HarborProject: "metis",
|
|
HarborRegistry: "registry.example",
|
|
}}
|
|
if err := app.ensureHarborProject(); err == nil {
|
|
t.Fatal("expected ensureHarborProject error")
|
|
}
|
|
if err := app.pruneHarborArtifacts("node", 0); err == nil {
|
|
t.Fatal("expected pruneHarborArtifacts error")
|
|
}
|
|
}
|
|
|
|
func TestServiceClusterAndRemotePodBranches(t *testing.T) {
|
|
origTokenPath := kubeServiceAccountTokenPath
|
|
origCAPath := kubeServiceAccountCAPath
|
|
dir := t.TempDir()
|
|
kubeServiceAccountTokenPath = filepath.Join(dir, "token")
|
|
kubeServiceAccountCAPath = filepath.Join(dir, "ca.crt")
|
|
t.Cleanup(func() {
|
|
kubeServiceAccountTokenPath = origTokenPath
|
|
kubeServiceAccountCAPath = origCAPath
|
|
})
|
|
if err := os.WriteFile(kubeServiceAccountTokenPath, []byte("tok"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
|
|
t.Setenv("KUBERNETES_SERVICE_PORT", "443")
|
|
|
|
srv := httptest.NewTLSServer(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": "b", "labels": map[string]string{"kubernetes.io/arch": "arm64", "node-role.kubernetes.io/worker": "true"}},
|
|
"spec": map[string]any{"unschedulable": false},
|
|
},
|
|
map[string]any{
|
|
"metadata": map[string]any{"name": "a", "labels": map[string]string{"kubernetes.io/arch": "arm64", "node-role.kubernetes.io/worker": "true"}},
|
|
"spec": map[string]any{"unschedulable": false},
|
|
},
|
|
},
|
|
})
|
|
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/pods"):
|
|
w.WriteHeader(http.StatusCreated)
|
|
case r.Method == http.MethodDelete:
|
|
w.WriteHeader(http.StatusOK)
|
|
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log"):
|
|
_, _ = w.Write([]byte("pod logs"))
|
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pods/"):
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"metadata": map[string]any{"name": filepath.Base(r.URL.Path)},
|
|
"status": map[string]any{
|
|
"phase": "Succeeded",
|
|
"message": `{"dest_path":"/tmp/out.img"}`,
|
|
"reason": "Completed",
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
|
|
if err := os.WriteFile(kubeServiceAccountCAPath, certPEM, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
client, err := inClusterKubeClient()
|
|
if err != nil {
|
|
t.Fatalf("inClusterKubeClient: %v", err)
|
|
}
|
|
client.baseURL = srv.URL
|
|
client.client = srv.Client()
|
|
kubeClientFactory = func() (*kubeClient, error) { return client, nil }
|
|
t.Cleanup(func() { kubeClientFactory = inClusterKubeClient })
|
|
|
|
var nodePayload map[string]any
|
|
if err := client.jsonRequest(http.MethodGet, "/api/v1/nodes", nil, &nodePayload); err != nil {
|
|
t.Fatalf("jsonRequest: %v", err)
|
|
}
|
|
if err := client.deleteRequest("/api/v1/nodes/a"); err != nil {
|
|
t.Fatalf("deleteRequest: %v", err)
|
|
}
|
|
if nodes := clusterNodes(); len(nodes) != 2 || nodes[0].Name != "a" {
|
|
t.Fatalf("clusterNodes = %#v", nodes)
|
|
}
|
|
|
|
app := newTestApp(t)
|
|
app.settings.Namespace = "maintenance"
|
|
app.settings.RunnerImageARM64 = "runner:arm64"
|
|
state, err := app.remotePodState(client, "metis-build-test")
|
|
if err != nil {
|
|
t.Fatalf("remotePodState: %v", err)
|
|
}
|
|
if state.Phase != "Succeeded" || state.Message == "" {
|
|
t.Fatalf("remotePodState = %#v", state)
|
|
}
|
|
logs, err := app.remotePodLogs(client, "metis-build-test")
|
|
if err != nil || logs != "pod logs" {
|
|
t.Fatalf("remotePodLogs = %q err=%v", logs, err)
|
|
}
|
|
if got := app.podImageForArch("amd64"); got != "" {
|
|
t.Fatalf("podImageForArch fallback = %q", got)
|
|
}
|
|
if got := app.podImageForArch("arm64"); got != "runner:arm64" {
|
|
t.Fatalf("podImageForArch arm64 = %q", got)
|
|
}
|
|
|
|
job := app.newJob("build", "titan-15", "titan-22", "/dev/sdz")
|
|
app.settings.HarborAPIBase = ""
|
|
app.runBuild(job, false)
|
|
if got := app.job(job.ID); got == nil || got.Status != JobError {
|
|
t.Fatalf("runBuild should fail without harbor creds: %#v", got)
|
|
}
|
|
if _, err := app.Replace("incomplete", "titan-22", "/dev/sdz"); err == nil {
|
|
t.Fatal("expected Replace to reject incomplete node")
|
|
}
|
|
}
|