metis/pkg/service/workflow_test.go

237 lines
7.9 KiB
Go

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.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:///tmp","model":"Host /tmp","transport":"test","type":"file","note":"Test-only host write target under /tmp","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":"/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")
}