292 lines
8.9 KiB
Go
292 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestUsageWritesSupportedCommands(t *testing.T) {
|
|
stdout, stderr := captureStreams(t, func() {
|
|
usage()
|
|
})
|
|
if stdout != "" {
|
|
t.Fatalf("usage wrote stdout: %q", stdout)
|
|
}
|
|
if !strings.Contains(stderr, "remote-flash") || !strings.Contains(stderr, "plan") {
|
|
t.Fatalf("usage output missing commands: %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestConfigFactsPlanBurnImageInjectAndServeCommands(t *testing.T) {
|
|
root := t.TempDir()
|
|
invPath, baseImage := writeTestInventory(t, root)
|
|
t.Setenv("METIS_INVENTORY_PATH", invPath)
|
|
t.Setenv("METIS_DATA_DIR", filepath.Join(root, "data"))
|
|
t.Setenv("METIS_BOOT_PATH", "")
|
|
t.Setenv("METIS_ROOT_PATH", "")
|
|
|
|
stdout, _ := captureStreams(t, func() {
|
|
configCmd([]string{"--inventory", invPath, "--node", "titan-15"})
|
|
})
|
|
if !strings.Contains(stdout, `"hostname": "titan-15"`) {
|
|
t.Fatalf("config output missing hostname: %s", stdout)
|
|
}
|
|
|
|
snapDir := filepath.Join(root, "snapshots")
|
|
if err := os.MkdirAll(snapDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(snapDir, "snap.json"), []byte(`{"hostname":"titan-15","kernel":"6.6.63","package_sample":{"containerd":"1.7"}}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stdout, _ = captureStreams(t, func() {
|
|
factsCmd([]string{"--inventory", invPath, "--snapshots", snapDir})
|
|
})
|
|
if !strings.Contains(stdout, `"class": "rpi4"`) {
|
|
t.Fatalf("facts output missing class summary: %s", stdout)
|
|
}
|
|
|
|
stdout, _ = captureStreams(t, func() {
|
|
planCmd([]string{"--inventory", invPath, "--node", "titan-15", "--device", "/dev/sdz", "--cache", filepath.Join(root, "cache")})
|
|
})
|
|
if !strings.Contains(stdout, `"node": "titan-15"`) || !strings.Contains(stdout, `"actions"`) {
|
|
t.Fatalf("plan output missing plan JSON: %s", stdout)
|
|
}
|
|
|
|
rootTools := fakeCommandDir(t, map[string]string{
|
|
"sfdisk": `cat <<'JSON'
|
|
{"partitiontable":{"sectorsize":512,"partitions":[{"start":3,"size":1,"type":"ef"},{"start":1,"size":2,"type":"83"}]}}
|
|
JSON`,
|
|
"debugfs": `if [[ "${1:-}" == "-w" ]]; then
|
|
cp "${3:-}" "${4:-}.commands"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "-R" ]]; then
|
|
state="${3:-}.commands"
|
|
set -- $2
|
|
case "${1:-}" in
|
|
stat)
|
|
mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)"
|
|
mode="${mode: -4}"
|
|
printf 'Mode: %s\n' "${mode}"
|
|
exit 0
|
|
;;
|
|
dump)
|
|
local_path="$(awk -v path="${2:-}" '$1=="write" && $3==path {print $2}' "${state}" | tail -n1)"
|
|
cat "${local_path}" > "${3:-}"
|
|
exit 0
|
|
;;
|
|
esac
|
|
fi
|
|
exit 0`,
|
|
})
|
|
t.Setenv("PATH", rootTools+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
stdout, _ = captureStreams(t, func() {
|
|
imageCmd([]string{"--inventory", invPath, "--node", "titan-15", "--output", filepath.Join(root, "out.img"), "--cache", filepath.Join(root, "cache")})
|
|
})
|
|
if !strings.Contains(stdout, "Wrote ") {
|
|
t.Fatalf("image output missing write confirmation: %s", stdout)
|
|
}
|
|
|
|
stdout, _ = captureStreams(t, func() {
|
|
burnCmd([]string{"--inventory", invPath, "--node", "titan-15", "--device", "/dev/sdz", "--cache", filepath.Join(root, "cache")})
|
|
})
|
|
if !strings.Contains(stdout, "Plan for titan-15 to /dev/sdz") {
|
|
t.Fatalf("burn output missing plan header: %s", stdout)
|
|
}
|
|
|
|
bootDir := filepath.Join(root, "boot")
|
|
rootDir := filepath.Join(root, "root")
|
|
if err := os.MkdirAll(bootDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
captureStreams(t, func() {
|
|
injectCmd([]string{"--inventory", invPath, "--node", "titan-15", "--boot", bootDir, "--root", rootDir})
|
|
})
|
|
if _, err := os.Stat(filepath.Join(rootDir, "etc/metis/node.json")); err != nil {
|
|
t.Fatalf("injectCmd did not write root file: %v", err)
|
|
}
|
|
|
|
listenAndServe = func(addr string, handler http.Handler) error {
|
|
if addr != ":0" {
|
|
t.Fatalf("unexpected bind addr: %s", addr)
|
|
}
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { listenAndServe = http.ListenAndServe })
|
|
t.Setenv("METIS_BIND_ADDR", ":0")
|
|
t.Setenv("METIS_INVENTORY_PATH", invPath)
|
|
serveCmd([]string{"--bind", ":0"})
|
|
_ = baseImage
|
|
}
|
|
|
|
func TestMainDispatchesConfig(t *testing.T) {
|
|
root := t.TempDir()
|
|
invPath, _ := writeTestInventory(t, root)
|
|
oldArgs := os.Args
|
|
os.Args = []string{"metis", "config", "--inventory", invPath, "--node", "titan-15"}
|
|
t.Cleanup(func() { os.Args = oldArgs })
|
|
main()
|
|
}
|
|
|
|
func TestRemoteCommandsAndHelpers(t *testing.T) {
|
|
root := t.TempDir()
|
|
invPath, baseImage := writeTestInventory(t, root)
|
|
fakeTools := fakeCommandDir(t, map[string]string{
|
|
"lsblk": `cat <<'JSON'
|
|
{"blockdevices":[{"name":"sdz","path":"/dev/sdz","rm":true,"hotplug":true,"size":"32000000000","model":"Micro SD","tran":"usb","type":"disk"}]}
|
|
JSON`,
|
|
"sfdisk": `cat <<'JSON'
|
|
{"partitiontable":{"sectorsize":512,"partitions":[{"start":3,"size":1,"type":"ef"},{"start":1,"size":2,"type":"83"}]}}
|
|
JSON`,
|
|
"debugfs": `if [[ "${1:-}" == "-w" ]]; then
|
|
cp "${3:-}" "${4:-}.commands"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "-R" ]]; then
|
|
state="${3:-}.commands"
|
|
set -- $2
|
|
case "${1:-}" in
|
|
stat)
|
|
mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)"
|
|
mode="${mode: -4}"
|
|
printf 'Mode: %s\n' "${mode}"
|
|
exit 0
|
|
;;
|
|
dump)
|
|
local_path="$(awk -v path="${2:-}" '$1=="write" && $3==path {print $2}' "${state}" | tail -n1)"
|
|
cat "${local_path}" > "${3:-}"
|
|
exit 0
|
|
;;
|
|
esac
|
|
fi
|
|
exit 0`,
|
|
"xz": `dest="${@: -1}"; cp "$dest" "$dest.xz"`,
|
|
"oras": `case "${1:-}" in
|
|
login|tag) exit 0 ;;
|
|
push) exit 0 ;;
|
|
pull)
|
|
outdir="${@: -1}"
|
|
cp "` + baseImage + `" "${outdir}/titan-15.img"
|
|
exit 0
|
|
;;
|
|
esac
|
|
exit 0`,
|
|
})
|
|
t.Setenv("PATH", fakeTools+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
stdout, _ := captureStreams(t, func() {
|
|
remoteDevicesCmd([]string{"--max-device-bytes", "40000000000", "--host-tmp-dir", filepath.Join(root, "host-tmp")})
|
|
})
|
|
if !strings.Contains(stdout, `"path":"/dev/sdz"`) {
|
|
t.Fatalf("remoteDevicesCmd output missing device: %s", stdout)
|
|
}
|
|
|
|
stdout, _ = captureStreams(t, func() {
|
|
remoteBuildCmd([]string{"--inventory", invPath, "--node", "titan-15", "--artifact-ref", "registry.example/metis/titan-15", "--build-tag", "build-1", "--work-dir", filepath.Join(root, "build"), "--cache", filepath.Join(root, "cache"), "--harbor-registry", "registry.example", "--harbor-username", "admin", "--harbor-password", "pw"})
|
|
})
|
|
if !strings.Contains(stdout, `"build_tag":"build-1"`) {
|
|
t.Fatalf("remoteBuildCmd output missing build tag: %s", stdout)
|
|
}
|
|
|
|
stdout, _ = captureStreams(t, func() {
|
|
remoteFlashCmd([]string{"--node", "titan-15", "--device", "hosttmp:///tmp", "--artifact-ref", "registry.example/metis/titan-15", "--work-dir", filepath.Join(root, "flash"), "--host-tmp-dir", filepath.Join(root, "host-tmp"), "--harbor-registry", "registry.example", "--harbor-username", "admin", "--harbor-password", "pw"})
|
|
})
|
|
if !strings.Contains(stdout, `"dest_path"`) {
|
|
t.Fatalf("remoteFlashCmd output missing dest_path: %s", stdout)
|
|
}
|
|
}
|
|
|
|
func captureStreams(t *testing.T, fn func()) (string, string) {
|
|
t.Helper()
|
|
oldStdout := os.Stdout
|
|
oldStderr := os.Stderr
|
|
stdoutR, stdoutW, _ := os.Pipe()
|
|
stderrR, stderrW, _ := os.Pipe()
|
|
os.Stdout = stdoutW
|
|
os.Stderr = stderrW
|
|
defer func() {
|
|
os.Stdout = oldStdout
|
|
os.Stderr = oldStderr
|
|
}()
|
|
done := make(chan struct {
|
|
out string
|
|
err string
|
|
}, 1)
|
|
go func() {
|
|
var outBuf bytes.Buffer
|
|
var errBuf bytes.Buffer
|
|
_, _ = io.Copy(&outBuf, stdoutR)
|
|
_, _ = io.Copy(&errBuf, stderrR)
|
|
done <- struct {
|
|
out string
|
|
err string
|
|
}{out: outBuf.String(), err: errBuf.String()}
|
|
}()
|
|
fn()
|
|
_ = stdoutW.Close()
|
|
_ = stderrW.Close()
|
|
captured := <-done
|
|
return captured.out, captured.err
|
|
}
|
|
|
|
func writeTestInventory(t *testing.T, root string) (string, string) {
|
|
t.Helper()
|
|
baseImage := filepath.Join(root, "base.img")
|
|
if err := os.WriteFile(baseImage, make([]byte, 4096), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
invPath := filepath.Join(root, "inventory.yaml")
|
|
inv := `classes:
|
|
- name: rpi4
|
|
arch: arm64
|
|
os: armbian
|
|
image: file://` + baseImage + `
|
|
checksum: sha256:` + sha256SumHex(t, make([]byte, 4096)) + `
|
|
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 AAA
|
|
`
|
|
if err := os.WriteFile(invPath, []byte(inv), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return invPath, baseImage
|
|
}
|
|
|
|
func sha256SumHex(t *testing.T, data []byte) string {
|
|
t.Helper()
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func fakeCommandDir(t *testing.T, scripts map[string]string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for name, body := range scripts {
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte("#!/usr/bin/env bash\nset -eu\n"+body+"\n"), 0o755); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|