2026-04-11 00:17:10 -03:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-19 21:54:51 -03:00
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-04-11 00:17:10 -03:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"metis/pkg/inventory"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestRemoteHelperBranches(t *testing.T) {
|
|
|
|
|
if got := prettyDeviceTarget(""); got != "the selected target" {
|
|
|
|
|
t.Fatalf("prettyDeviceTarget empty = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := prettyDeviceTarget("hosttmp:///tmp"); got != "/tmp" {
|
|
|
|
|
t.Fatalf("prettyDeviceTarget hosttmp = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := ramp(0, 10, 20, 1, 2); got != 1 {
|
|
|
|
|
t.Fatalf("ramp before start = %v", got)
|
|
|
|
|
}
|
2026-04-21 05:45:08 -03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-11 00:17:10 -03:00
|
|
|
if got := mountedHostTmpDir("/tmp/metis-flash-test"); got != "/host-tmp/metis-flash-test" {
|
|
|
|
|
t.Fatalf("mountedHostTmpDir = %q", got)
|
|
|
|
|
}
|
2026-04-21 05:45:08 -03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-11 00:17:10 -03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-21 05:45:08 -03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-11 00:17:10 -03:00
|
|
|
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)
|
2026-04-21 05:45:08 -03:00
|
|
|
app.settings.HarborRegistry = "registry.example"
|
|
|
|
|
app.settings.HarborProject = "metis"
|
2026-04-11 00:17:10 -03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-21 05:45:08 -03:00
|
|
|
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")
|
|
|
|
|
}
|
2026-04-11 00:17:10 -03:00
|
|
|
if got := inventoryNodeArch(&inventory.NodeSpec{}, &inventory.NodeClass{Arch: "amd64"}); got != "amd64" {
|
|
|
|
|
t.Fatalf("inventoryNodeArch = %q", got)
|
|
|
|
|
}
|
|
|
|
|
worker := remoteWorkerEntrypoint(true, "--node", "n1")
|
|
|
|
|
if !strings.Contains(worker, "metis-runtime-env.sh") || !strings.Contains(worker, "metis-ssh-env.sh") {
|
|
|
|
|
t.Fatalf("remoteWorkerEntrypoint missing expected sources: %s", worker)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSelectBuilderHostPrefersWorkerAndArch(t *testing.T) {
|
|
|
|
|
kube := fakeKubeServer(t)
|
|
|
|
|
installKubeFactory(t, kube)
|
|
|
|
|
app := newTestApp(t)
|
|
|
|
|
node, err := app.selectBuilderHost("arm64", "titan-22")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("selectBuilderHost: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if node.Name != "titan-22" {
|
|
|
|
|
t.Fatalf("expected titan-22 builder, got %s", node.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSelectBuilderHostErrorBranch(t *testing.T) {
|
|
|
|
|
kube := fakeKubeServer(t)
|
|
|
|
|
installKubeFactory(t, kube)
|
|
|
|
|
app := newTestApp(t)
|
|
|
|
|
if _, err := app.selectBuilderHost("s390x", "titan-22"); err == nil {
|
|
|
|
|
t.Fatal("expected selectBuilderHost error")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-19 21:54:51 -03:00
|
|
|
|
|
|
|
|
func TestSelectBuilderHostAvoidsBusyBuilderWhenPeersAreFree(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": "titan-04",
|
|
|
|
|
"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": "titan-05",
|
|
|
|
|
"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{
|
|
|
|
|
map[string]any{
|
|
|
|
|
"metadata": map[string]any{"labels": map[string]string{"app": "metis-remote", "metis-run": "build"}},
|
|
|
|
|
"spec": map[string]any{"nodeName": "titan-04"},
|
|
|
|
|
"status": map[string]any{"phase": "Running"},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer kube.Close()
|
|
|
|
|
installKubeFactory(t, kube)
|
|
|
|
|
app := newTestApp(t)
|
|
|
|
|
app.settings.Namespace = "maintenance"
|
|
|
|
|
node, err := app.selectBuilderHost("arm64", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("selectBuilderHost: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if node.Name != "titan-05" {
|
|
|
|
|
t.Fatalf("expected titan-05 builder, got %s", node.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-21 05:45:08 -03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|