299 lines
9.3 KiB
Go
299 lines
9.3 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"metis/pkg/sentinel"
|
|
)
|
|
|
|
func TestUIAuthGuardsState(t *testing.T) {
|
|
app := newTestApp(t)
|
|
handler := app.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("expected forbidden, got %d", resp.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.Header.Set("X-Auth-Request-User", "brad")
|
|
req.Header.Set("X-Auth-Request-Groups", "admin")
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUIAuthAcceptsForwardedSlashGroups(t *testing.T) {
|
|
app := newTestApp(t)
|
|
handler := app.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.Header.Set("X-Forwarded-User", "brad")
|
|
req.Header.Set("X-Forwarded-Groups", "/admin,/ops")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUIAuthRejectsUserWithoutAllowedGroup(t *testing.T) {
|
|
app := newTestApp(t)
|
|
handler := app.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("expected forbidden, got %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestStateJSONUsesLowerCaseNodeFields(t *testing.T) {
|
|
app := newTestApp(t)
|
|
handler := app.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.Header.Set("X-Auth-Request-User", "brad")
|
|
req.Header.Set("X-Auth-Request-Groups", "admin")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
body := resp.Body.String()
|
|
if !strings.Contains(body, `"name":"titan-15"`) {
|
|
t.Fatalf("expected lowercase node name field in json, got %s", body)
|
|
}
|
|
}
|
|
|
|
func TestStateFiltersNodesWithoutCompleteReplacementDefinition(t *testing.T) {
|
|
dir := t.TempDir()
|
|
baseImage := filepath.Join(dir, "base.img")
|
|
if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256([]byte("test-image"))
|
|
inventoryPath := filepath.Join(dir, "inventory.yaml")
|
|
inv := `
|
|
classes:
|
|
- name: ready
|
|
arch: arm64
|
|
os: armbian
|
|
image: file://` + baseImage + `
|
|
checksum: sha256:` + hex.EncodeToString(sum[:]) + `
|
|
k3s_version: v1.31.5+k3s1
|
|
- name: incomplete
|
|
arch: arm64
|
|
os: ubuntu
|
|
image: file://` + baseImage + `
|
|
nodes:
|
|
- name: titan-ready
|
|
class: ready
|
|
hostname: titan-ready
|
|
ip: 192.168.22.240
|
|
k3s_role: agent
|
|
k3s_url: https://192.168.22.7:6443
|
|
k3s_token: token
|
|
ssh_user: atlas
|
|
ssh_authorized_keys:
|
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
|
|
- name: titan-incomplete
|
|
class: incomplete
|
|
hostname: titan-incomplete
|
|
ip: 192.168.22.241
|
|
k3s_role: agent
|
|
k3s_url: https://192.168.22.7:6443
|
|
k3s_token: token
|
|
ssh_user: atlas
|
|
ssh_authorized_keys:
|
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
|
|
`
|
|
if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := Settings{
|
|
BindAddr: ":0",
|
|
InventoryPath: inventoryPath,
|
|
CacheDir: filepath.Join(dir, "cache"),
|
|
ArtifactDir: filepath.Join(dir, "artifacts"),
|
|
HistoryPath: filepath.Join(dir, "history.jsonl"),
|
|
SnapshotsPath: filepath.Join(dir, "snapshots.json"),
|
|
TargetsPath: filepath.Join(dir, "targets.json"),
|
|
DefaultFlashHost: "titan-22",
|
|
FlashHosts: []string{"titan-22"},
|
|
LocalHost: "titan-22",
|
|
AllowedGroups: []string{"admin", "maintenance"},
|
|
MaxDeviceBytes: 300000000000,
|
|
}
|
|
app, err := NewApp(settings)
|
|
if err != nil {
|
|
t.Fatalf("new app: %v", err)
|
|
}
|
|
state := app.State("titan-22")
|
|
if len(state.Nodes) != 1 || state.Nodes[0].Name != "titan-ready" {
|
|
t.Fatalf("expected only titan-ready in state nodes, got %+v", state.Nodes)
|
|
}
|
|
}
|
|
|
|
func TestInternalSnapshotAndWatch(t *testing.T) {
|
|
app := newTestApp(t)
|
|
handler := app.Handler()
|
|
|
|
payload := `{"node":"titan-15","collected_at":"2026-03-31T12:00:00Z","snapshot":{"hostname":"titan-15","kernel":"6.6.63","os_image":"Armbian","k3s_version":"v1.31.5+k3s1","containerd":"2.0.0","package_sample":{"containerd":"2.0.0"}}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("snapshot failed: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/internal/sentinel/watch", nil)
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("watch failed: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var event Event
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &event); err != nil {
|
|
t.Fatalf("decode watch response: %v", err)
|
|
}
|
|
if event.Kind != "sentinel.watch" {
|
|
t.Fatalf("unexpected event kind: %s", event.Kind)
|
|
}
|
|
|
|
metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
metricsResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(metricsResp, metricsReq)
|
|
body := metricsResp.Body.String()
|
|
if !strings.Contains(body, `metis_sentinel_snapshots_total{node="titan-15",status="ok"} 1`) {
|
|
t.Fatalf("missing snapshot metric: %s", body)
|
|
}
|
|
if !strings.Contains(body, `metis_sentinel_watch_total{status="ok"} 1`) {
|
|
t.Fatalf("missing watch metric: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestRequestValuesJSONBody(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader(`{"node":"titan-13","host":"titan-20","device":"hosttmp:///tmp"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
values := requestValues(req)
|
|
if values["node"] != "titan-13" {
|
|
t.Fatalf("expected node titan-13, got %q", values["node"])
|
|
}
|
|
if values["host"] != "titan-20" {
|
|
t.Fatalf("expected host titan-20, got %q", values["host"])
|
|
}
|
|
if values["device"] != "hosttmp:///tmp" {
|
|
t.Fatalf("expected device hosttmp:///tmp, got %q", values["device"])
|
|
}
|
|
}
|
|
|
|
func TestHandleBuildReturnsConflictForActiveNodeJob(t *testing.T) {
|
|
app := newTestApp(t)
|
|
app.newJob("replace", "titan-15", "titan-22", "/dev/sdk")
|
|
handler := app.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/jobs/build", strings.NewReader(`{"node":"titan-15"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Auth-Request-User", "brad")
|
|
req.Header.Set("X-Auth-Request-Groups", "admin")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusConflict {
|
|
t.Fatalf("expected conflict, got %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if !strings.Contains(resp.Body.String(), "already has an active replace job") {
|
|
t.Fatalf("expected active job message, got %q", resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func newTestApp(t *testing.T) *App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
baseImage := filepath.Join(dir, "base.img")
|
|
if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256([]byte("test-image"))
|
|
inventoryPath := filepath.Join(dir, "inventory.yaml")
|
|
inv := `
|
|
classes:
|
|
- name: rpi4
|
|
arch: arm64
|
|
os: armbian
|
|
image: file://` + baseImage + `
|
|
checksum: sha256:` + hex.EncodeToString(sum[:]) + `
|
|
k3s_version: v1.31.5+k3s1
|
|
nodes:
|
|
- name: titan-15
|
|
class: rpi4
|
|
hostname: titan-15
|
|
ip: 192.168.22.43
|
|
k3s_role: agent
|
|
k3s_url: https://192.168.22.7:6443
|
|
k3s_token: token
|
|
ssh_user: atlas
|
|
ssh_authorized_keys:
|
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
|
|
`
|
|
if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := Settings{
|
|
BindAddr: ":0",
|
|
InventoryPath: inventoryPath,
|
|
CacheDir: filepath.Join(dir, "cache"),
|
|
ArtifactDir: filepath.Join(dir, "artifacts"),
|
|
HistoryPath: filepath.Join(dir, "history.jsonl"),
|
|
SnapshotsPath: filepath.Join(dir, "snapshots.json"),
|
|
TargetsPath: filepath.Join(dir, "targets.json"),
|
|
DefaultFlashHost: "titan-22",
|
|
FlashHosts: []string{"titan-22"},
|
|
LocalHost: "titan-22",
|
|
AllowedGroups: []string{"admin", "maintainer"},
|
|
MaxDeviceBytes: 300000000000,
|
|
}
|
|
app, err := NewApp(settings)
|
|
if err != nil {
|
|
t.Fatalf("new app: %v", err)
|
|
}
|
|
if err := app.StoreSnapshot(SnapshotRecord{
|
|
Node: "titan-17",
|
|
CollectedAt: time.Now().UTC().Add(-10 * time.Minute),
|
|
Snapshot: sentinelSnapshot("titan-17", "6.6.63"),
|
|
}); err != nil {
|
|
t.Fatalf("seed snapshot: %v", err)
|
|
}
|
|
return app
|
|
}
|
|
|
|
func sentinelSnapshot(hostname, kernel string) sentinel.Snapshot {
|
|
return sentinel.Snapshot{
|
|
Hostname: hostname,
|
|
Kernel: kernel,
|
|
OSImage: "Armbian",
|
|
K3sVersion: "v1.31.5+k3s1",
|
|
Containerd: "2.0.0",
|
|
PackageSample: map[string]string{"containerd": "2.0.0"},
|
|
}
|
|
}
|