package service import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" ) func TestRefreshDevicesAndReplacementWorkflow(t *testing.T) { kube := fakeKubeServer(t) harbor := fakeHarborServer(t, true) app := newTestApp(t) app.settings.Namespace = "maintenance" app.settings.RunnerImageARM64 = "runner:arm64" app.settings.HarborAPIBase = harbor.URL + "/api/v2.0" app.settings.HarborUsername = "admin" app.settings.HarborPassword = "pw" app.settings.HarborProject = "metis" app.settings.HarborRegistry = "registry.example" app.settings.ArtifactStatePath = filepath.Join(t.TempDir(), "artifacts.json") installKubeFactory(t, kube) devices, err := app.RefreshDevices("titan-22") if err != nil { t.Fatalf("RefreshDevices: %v", err) } if len(devices) < 2 || devices[0].Path != "/dev/sdz" { t.Fatalf("unexpected devices: %+v", devices) } cached, err := app.ListDevices("titan-22") if err != nil || len(cached) != len(devices) { t.Fatalf("ListDevices cache mismatch: %+v err=%v", cached, err) } state := app.State("titan-22") if state.PreferredDevice != "/dev/sdz" { t.Fatalf("expected preferred device /dev/sdz, got %q", state.PreferredDevice) } job, err := app.Replace("titan-15", "titan-22", "/dev/sdz") if err != nil { t.Fatalf("Replace: %v", err) } waitForJobState(t, app, job.ID, JobDone) if got := app.job(job.ID); got == nil || got.Status != JobDone { t.Fatalf("replace job did not finish successfully: %#v", got) } if got := app.artifacts()["titan-15"].Ref; got != "registry.example/metis/titan-15:latest" { t.Fatalf("artifact not recorded: %q", got) } } func TestRemotePodStateAndLogsHelpers(t *testing.T) { kube := fakeKubeServer(t) installKubeFactory(t, kube) app := newTestApp(t) app.settings.Namespace = "maintenance" client, err := kubeClientFactory() if err != nil { t.Fatalf("kube client: %v", err) } state, err := app.remotePodState(client, "metis-build-test") if err != nil { t.Fatalf("remotePodState: %v", err) } if state.Name != "metis-build-test" || state.Reason != "Completed" || !strings.Contains(state.Message, "build") { t.Fatalf("unexpected pod state: %#v", state) } logs, err := app.remotePodLogs(client, "metis-build-test") if err != nil || !strings.Contains(logs, "build logs") { t.Fatalf("remotePodLogs: logs=%q err=%v", logs, err) } } func TestHarborProjectCreationAndPrune(t *testing.T) { harbor := fakeHarborServer(t, false) app := &App{settings: Settings{ HarborAPIBase: harbor.URL + "/api/v2.0", HarborUsername: "admin", HarborPassword: "pw", HarborProject: "metis", HarborRegistry: "registry.example", }, metrics: NewMetrics()} if got := app.artifactRepo("titan-15"); got != "registry.example/metis/titan-15" { t.Fatalf("artifactRepo = %q", got) } if err := app.ensureHarborProject(); err != nil { t.Fatalf("ensureHarborProject: %v", err) } if err := app.pruneHarborArtifacts("titan-15", 1); err != nil { t.Fatalf("pruneHarborArtifacts: %v", err) } } func TestKubeJSONAndDeleteRequests(t *testing.T) { kube := fakeKubeServer(t) client := kubeClientFactoryForURL(kube.URL, kube.Client()) var payload map[string]any if err := client.jsonRequest(http.MethodGet, "/api/v1/nodes", nil, &payload); err != nil { t.Fatalf("jsonRequest: %v", err) } if err := client.deleteRequest("/api/v1/nodes/titan-15"); err != nil { t.Fatalf("deleteRequest: %v", err) } } func TestBuildStageAndArchiveHelpers(t *testing.T) { if got := remoteArtifactNoteForTest(t); got != "registry.example/metis/titan-15:latest" { t.Fatalf("remoteArtifactNote = %q", got) } } func waitForJobState(t *testing.T, app *App, id string, want JobStatus) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if got := app.job(id); got != nil { if got.Status == want { return } if got.Status == JobError { t.Fatalf("job %s failed: %s", id, got.Error) } } time.Sleep(10 * time.Millisecond) } t.Fatalf("job %s never reached state %s", id, want) } func installKubeFactory(t *testing.T, srv *httptest.Server) { t.Helper() orig := kubeClientFactory kubeClientFactory = func() (*kubeClient, error) { return &kubeClient{baseURL: srv.URL, token: "tok", client: srv.Client()}, nil } t.Cleanup(func() { kubeClientFactory = orig }) } func kubeClientFactoryForURL(baseURL string, client *http.Client) *kubeClient { return &kubeClient{baseURL: baseURL, token: "tok", client: client} } func fakeKubeServer(t *testing.T) *httptest.Server { t.Helper() return 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": "titan-22", "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{}}) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/pods"): w.WriteHeader(http.StatusCreated) case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/pods/"): w.WriteHeader(http.StatusOK) case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/nodes/"): w.WriteHeader(http.StatusOK) case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pods/") && strings.HasSuffix(r.URL.Path, "/log"): _, _ = w.Write([]byte("build logs from kubelet")) case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pods/"): podName := filepath.Base(strings.TrimSuffix(r.URL.Path, "/log")) message := `{}` switch { case strings.Contains(podName, "devices"): message = `{"devices":[{"name":"sdz","path":"/dev/sdz","model":"Micro SD","transport":"usb","type":"disk","removable":true,"hotplug":true,"size_bytes":32000000000},{"name":"tmp","path":"hosttmp:///var/tmp/metis-flash-test","model":"Host scratch","transport":"test","type":"file","note":"Test-only host write target under /var/tmp/metis-flash-test","size_bytes":1}]}` case strings.Contains(podName, "build"): message = `{"local_path":"/workspace/build/titan-15.img.xz","compressed":true,"size_bytes":1234,"build_tag":"build-1"}` case strings.Contains(podName, "flash"): message = `{"dest_path":"/var/tmp/metis-flash-test/titan-15.img"}` } _ = json.NewEncoder(w).Encode(map[string]any{ "metadata": map[string]any{"name": podName}, "status": map[string]any{ "phase": "Succeeded", "message": message, "reason": "Completed", }, }) default: http.NotFound(w, r) } })) } func fakeHarborServer(t *testing.T, projectExists bool) *httptest.Server { t.Helper() return 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"): if projectExists { _ = json.NewEncoder(w).Encode([]map[string]string{{"name": "metis"}}) return } _ = json.NewEncoder(w).Encode([]map[string]string{}) 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.NotFound(w, r) } })) } func remoteArtifactNoteForTest(t *testing.T) string { t.Helper() app := &App{ settings: Settings{HarborRegistry: "registry.example", HarborProject: "metis"}, artifactStore: map[string]ArtifactSummary{ "titan-15": {Node: "titan-15", Ref: "registry.example/metis/titan-15:latest"}, }, } return app.remoteArtifactNote("titan-15") }