239 lines
8.3 KiB
Go
239 lines
8.3 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.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","verified":true,"verification_kind":"image-file","verification_summary":"Verified image layout at /var/tmp/metis-flash-test/titan-15.img; boot and writable partitions are present."}`
|
|
}
|
|
_ = 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")
|
|
}
|