343 lines
12 KiB
Go
343 lines
12 KiB
Go
package plan
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"metis/pkg/config"
|
|
"metis/pkg/inventory"
|
|
)
|
|
|
|
func TestPlanBuildFilesAndExecuteBranches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
base := filepath.Join(dir, "base.img")
|
|
baseContent := []byte("image")
|
|
if err := os.WriteFile(base, baseContent, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(baseContent)
|
|
inv := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{
|
|
Name: "rpi4",
|
|
Arch: "arm64",
|
|
OS: "armbian",
|
|
Image: "file://" + base,
|
|
Checksum: "sha256:" + hex.EncodeToString(sum[:]),
|
|
DefaultLabels: map[string]string{
|
|
"node-role.kubernetes.io/worker": "true",
|
|
},
|
|
BootOverlay: filepath.Join(dir, "boot-overlay"),
|
|
RootOverlay: filepath.Join(dir, "root-overlay"),
|
|
}},
|
|
Nodes: []inventory.NodeSpec{{
|
|
Name: "titan-15",
|
|
Class: "rpi4",
|
|
Hostname: "titan-15",
|
|
IP: "192.168.22.43",
|
|
K3sRole: "agent",
|
|
K3sURL: "https://192.168.22.7:6443",
|
|
K3sToken: "token",
|
|
SSHUser: "atlas",
|
|
SSHAuthorized: []string{"ssh-ed25519 AAA"},
|
|
LonghornDisks: []inventory.LonghornDisk{{Mountpoint: "/var/lib/longhorn", UUID: "u1"}},
|
|
}},
|
|
}
|
|
if err := os.MkdirAll(inv.Classes[0].BootOverlay, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(inv.Classes[0].RootOverlay, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(inv.Classes[0].BootOverlay, "boot.txt"), []byte("boot"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(inv.Classes[0].RootOverlay, "root.txt"), []byte("root"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := Build(inv, "missing", "", dir); err == nil {
|
|
t.Fatal("expected Build to fail for missing node")
|
|
}
|
|
p, err := Build(inv, "titan-15", "", dir)
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if p.Device != "/dev/sdX" || !strings.Contains(strings.Join(actionDetails(p.Actions), " "), "Inject hostname/network/k3s config") {
|
|
t.Fatalf("unexpected plan: %#v", p)
|
|
}
|
|
if got := cacheName("foo.img.xz"); got != "foo.img" {
|
|
t.Fatalf("cacheName = %q", got)
|
|
}
|
|
|
|
files, err := Files(inv, "titan-15")
|
|
if err != nil {
|
|
t.Fatalf("Files: %v", err)
|
|
}
|
|
if len(files) == 0 {
|
|
t.Fatal("expected files")
|
|
}
|
|
if got := cloudInitUserData(nil, nil); got != "" {
|
|
t.Fatalf("cloudInitUserData nil = %q", got)
|
|
}
|
|
if got := allowK3sNodeLabel("agent", "node-role.kubernetes.io/master"); got {
|
|
t.Fatal("agent should reject node-role labels")
|
|
}
|
|
|
|
// Inject is a thin wrapper around maybeInject; exercise both the no-op and
|
|
// path-setting branches.
|
|
if err := Inject(inv, "titan-15", "", ""); err != nil {
|
|
t.Fatalf("Inject noop: %v", err)
|
|
}
|
|
boot := filepath.Join(dir, "boot")
|
|
root := filepath.Join(dir, "root")
|
|
if err := os.MkdirAll(boot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Inject(inv, "titan-15", boot, root); err != nil {
|
|
t.Fatalf("Inject with paths: %v", err)
|
|
}
|
|
|
|
cacheDir := filepath.Join(dir, "cache")
|
|
output := filepath.Join(dir, "output.img")
|
|
t.Setenv("PATH", dir)
|
|
if err := BuildImageFile(context.Background(), inv, "titan-15", cacheDir, output); err == nil {
|
|
t.Fatal("expected BuildImageFile to fail without xz/debugfs setup")
|
|
}
|
|
if _, err := Execute(inv, "titan-15", "/dev/sdX", cacheDir, true); err == nil {
|
|
t.Fatal("expected Execute to reject placeholder device")
|
|
}
|
|
if _, err := Execute(inv, "titan-15", "/dev/sdz", cacheDir, false); err != nil {
|
|
t.Fatalf("Execute dry-run: %v", err)
|
|
}
|
|
}
|
|
|
|
func actionDetails(actions []Action) []string {
|
|
out := make([]string, 0, len(actions))
|
|
for _, action := range actions {
|
|
out = append(out, action.Detail)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestPlanMiscBranches(t *testing.T) {
|
|
if !NextRunStale(timeNow().Add(-time.Hour), time.Minute) {
|
|
t.Fatal("expected NextRunStale")
|
|
}
|
|
}
|
|
|
|
func TestBuildImageFileErrorBranches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
raw := filepath.Join(dir, "base.img")
|
|
if err := os.WriteFile(raw, []byte("image"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := imageChecksum(t, raw)
|
|
inv := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file://" + raw, Checksum: sum}},
|
|
Nodes: []inventory.NodeSpec{{Name: "n1", Class: "c1", Hostname: "n1", K3sRole: "agent"}},
|
|
}
|
|
if err := BuildImageFile(context.Background(), inv, "missing", filepath.Join(dir, "cache"), filepath.Join(dir, "out.img")); err == nil {
|
|
t.Fatal("expected missing node to fail plan build")
|
|
}
|
|
|
|
badChecksum := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file://" + raw, Checksum: "sha256:bad"}},
|
|
Nodes: []inventory.NodeSpec{{Name: "n1", Class: "c1", Hostname: "n1", K3sRole: "agent"}},
|
|
}
|
|
if err := BuildImageFile(context.Background(), badChecksum, "n1", filepath.Join(dir, "cache-bad"), filepath.Join(dir, "bad.img")); err == nil {
|
|
t.Fatal("expected image verification failure")
|
|
}
|
|
|
|
blockedParent := filepath.Join(dir, "blocked-parent")
|
|
if err := os.WriteFile(blockedParent, []byte("not-a-dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := BuildImageFile(context.Background(), inv, "n1", filepath.Join(dir, "cache-copy"), filepath.Join(blockedParent, "out.img")); err == nil {
|
|
t.Fatal("expected base-image copy failure")
|
|
}
|
|
|
|
overlayMissing := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file://" + raw, Checksum: sum, RootOverlay: filepath.Join(dir, "missing-overlay")}},
|
|
Nodes: []inventory.NodeSpec{{Name: "n1", Class: "c1", Hostname: "n1", K3sRole: "agent"}},
|
|
}
|
|
if err := BuildImageFile(context.Background(), overlayMissing, "n1", filepath.Join(dir, "cache-overlay"), filepath.Join(dir, "overlay.img")); err == nil {
|
|
t.Fatal("expected overlay resolution failure")
|
|
}
|
|
|
|
t.Setenv("PATH", t.TempDir())
|
|
if err := BuildImageFile(context.Background(), inv, "n1", filepath.Join(dir, "cache-inject"), filepath.Join(dir, "inject.img")); err == nil {
|
|
t.Fatal("expected rootfs injection failure without helper tools")
|
|
}
|
|
}
|
|
|
|
func TestExecuteAdditionalErrorBranches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
raw := filepath.Join(dir, "base.img")
|
|
if err := os.WriteFile(raw, []byte("image"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := imageChecksum(t, raw)
|
|
inv := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file://" + raw, Checksum: sum}},
|
|
Nodes: []inventory.NodeSpec{{Name: "n1", Class: "c1", Hostname: "n1", K3sRole: "agent"}},
|
|
}
|
|
if _, err := Execute(inv, "missing", filepath.Join(dir, "disk.img"), filepath.Join(dir, "cache-missing"), false); err == nil {
|
|
t.Fatal("expected missing node execute failure")
|
|
}
|
|
|
|
badChecksum := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file://" + raw, Checksum: "sha256:bad"}},
|
|
Nodes: []inventory.NodeSpec{{Name: "n1", Class: "c1", Hostname: "n1", K3sRole: "agent"}},
|
|
}
|
|
if _, err := Execute(badChecksum, "n1", filepath.Join(dir, "disk-bad.img"), filepath.Join(dir, "cache-bad"), false); err == nil {
|
|
t.Fatal("expected execute download verification failure")
|
|
}
|
|
|
|
blockedParent := filepath.Join(dir, "blocked-parent")
|
|
if err := os.WriteFile(blockedParent, []byte("not-a-dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Execute(inv, "n1", filepath.Join(blockedParent, "disk.img"), filepath.Join(dir, "cache-copy"), true); err == nil {
|
|
t.Fatal("expected execute write failure")
|
|
}
|
|
|
|
blockedRoot := filepath.Join(dir, "blocked-root")
|
|
if err := os.WriteFile(blockedRoot, []byte("not-a-dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("METIS_ROOT_PATH", blockedRoot)
|
|
if _, err := Execute(inv, "n1", filepath.Join(dir, "disk-inject.img"), filepath.Join(dir, "cache-inject"), true); err == nil {
|
|
t.Fatal("expected execute inject failure")
|
|
}
|
|
t.Setenv("METIS_ROOT_PATH", "")
|
|
t.Setenv("METIS_BOOT_PATH", "")
|
|
if got := maybeAutoMount("/dev/loop9"); got != nil {
|
|
t.Fatalf("expected automount disabled, got %#v", got)
|
|
}
|
|
|
|
scripts := filepath.Join(dir, "bin")
|
|
if err := os.MkdirAll(scripts, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write := func(name, body string) {
|
|
path := filepath.Join(scripts, 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)
|
|
}
|
|
}
|
|
write("mount", `exit 2`)
|
|
write("umount", `exit 0`)
|
|
write("losetup", `exit 0`)
|
|
t.Setenv("PATH", scripts+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("METIS_AUTO_MOUNT", "1")
|
|
if got := maybeAutoMount("/dev/loop9"); got != nil {
|
|
t.Fatalf("expected automount setup failure to be ignored, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestInjectAdditionalBranches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
inv := &inventory.Inventory{
|
|
Classes: []inventory.NodeClass{{Name: "c1", Image: "file:///tmp/base.img"}},
|
|
Nodes: []inventory.NodeSpec{{
|
|
Name: "n1",
|
|
Class: "c1",
|
|
Hostname: "n1",
|
|
IP: "10.0.0.1",
|
|
K3sRole: "agent",
|
|
SSHUser: "atlas",
|
|
SSHAuthorized: []string{"ssh-ed25519 AAA"},
|
|
}},
|
|
}
|
|
root := filepath.Join(dir, "root")
|
|
boot := filepath.Join(dir, "boot")
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(boot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Setenv("METIS_ROOT_PATH", root)
|
|
if err := maybeInject(inv, "missing"); err == nil {
|
|
t.Fatal("expected maybeInject to report missing node")
|
|
}
|
|
|
|
t.Setenv("METIS_BOOT_PATH", boot)
|
|
t.Setenv("METIS_ROOT_PATH", "")
|
|
if err := maybeInject(inv, "n1"); err != nil {
|
|
t.Fatalf("maybeInject boot-only: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(boot, "user-data")); err != nil {
|
|
t.Fatalf("expected boot-only injection to keep boot files: %v", err)
|
|
}
|
|
|
|
t.Setenv("METIS_BOOT_PATH", "")
|
|
t.Setenv("METIS_ROOT_PATH", root)
|
|
if err := maybeInject(inv, "n1"); err != nil {
|
|
t.Fatalf("maybeInject root-only: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "etc/metis/node.json")); err != nil {
|
|
t.Fatalf("expected root-only injection to keep root files: %v", err)
|
|
}
|
|
|
|
oldBoot := filepath.Join(dir, "old-boot")
|
|
oldRoot := filepath.Join(dir, "old-root")
|
|
if err := os.Setenv("METIS_BOOT_PATH", oldBoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Setenv("METIS_ROOT_PATH", oldRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Inject(inv, "n1", "", ""); err != nil {
|
|
t.Fatalf("Inject with existing env restoration: %v", err)
|
|
}
|
|
if os.Getenv("METIS_BOOT_PATH") != oldBoot || os.Getenv("METIS_ROOT_PATH") != oldRoot {
|
|
t.Fatalf("Inject did not restore env: boot=%q root=%q", os.Getenv("METIS_BOOT_PATH"), os.Getenv("METIS_ROOT_PATH"))
|
|
}
|
|
}
|
|
|
|
func TestInjectHelperErrorBranches(t *testing.T) {
|
|
if got := fstabAppendContent(&config.NodeConfig{Fstab: []config.FstabEntry{
|
|
{Label: "DATA", Mountpoint: "/mnt/data", FS: "ext4", Options: "defaults"},
|
|
{Mountpoint: "/mnt/empty", FS: "none", Options: "defaults"},
|
|
}}); !strings.Contains(got, "LABEL=DATA /mnt/data ext4 defaults 0 0") || !strings.Contains(got, "none /mnt/empty none defaults 0 0") {
|
|
t.Fatalf("fstab label/default branches missing: %s", got)
|
|
}
|
|
|
|
t.Setenv("VAULT_ADDR", "http://127.0.0.1:1")
|
|
if got := fetchSecrets("n1"); got != nil {
|
|
t.Fatalf("expected Vault fetch failure to return nil, got %#v", got)
|
|
}
|
|
|
|
files, err := collectOverlays(nil)
|
|
if err != nil || len(files) != 0 {
|
|
t.Fatalf("collectOverlays(nil) = %#v err=%v", files, err)
|
|
}
|
|
if _, err := collectOverlays(&inventory.NodeClass{BootOverlay: filepath.Join(t.TempDir(), "missing-boot")}); err == nil {
|
|
t.Fatal("expected missing boot overlay to fail")
|
|
}
|
|
if _, err := collectOverlays(&inventory.NodeClass{RootOverlay: filepath.Join(t.TempDir(), "missing-root")}); err == nil {
|
|
t.Fatal("expected missing root overlay to fail")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Symlink(filepath.Join(dir, "missing-target"), filepath.Join(dir, "broken-link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := overlayFiles(dir, true); err == nil {
|
|
t.Fatal("expected broken overlay symlink to fail")
|
|
}
|
|
}
|
|
|
|
func timeNow() time.Time { return time.Now() }
|