dfd5d731ee
- LocalAPIConfig.island_bridge + island_guest_addr (+ IslandEnabled, Validate all-or-nothing + CIDR guard) - buildBringUpConfig attaches static net1 (island) on provision + DR when set; absent otherwise (pre-R-50 byte-for-byte). Plumbed from cfg.LocalAPI at both RunBringUp sites. Endpoint already follows listen_addr (A0: no template change). - healer stays eth0-only (A3 verify-only) — red-proof test locks the scoping - example config + firewall example rewritten for the island; REUSE updated - 3 non-hollow tests; full green. MinAgent unchanged. Coupling: host-install island config requires agent >= 0.96.0 (vouch first).
217 lines
7.1 KiB
Go
217 lines
7.1 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestRestoreTestPBSRestoreTimeout mirrors the BackupCadence accessor contract: positive as-is,
|
|
// 0 → default (120m), negative → default.
|
|
func TestRestoreTestPBSRestoreTimeout(t *testing.T) {
|
|
cases := []struct {
|
|
secs int
|
|
want time.Duration
|
|
}{
|
|
{0, 120 * time.Minute},
|
|
{-5, 120 * time.Minute},
|
|
{1800, 30 * time.Minute},
|
|
{7200, 120 * time.Minute},
|
|
}
|
|
for _, c := range cases {
|
|
got := BackupConfig{RestoreTestPBSRestoreTimeoutSeconds: c.secs}.RestoreTestPBSRestoreTimeout()
|
|
if got != c.want {
|
|
t.Errorf("RestoreTestPBSRestoreTimeout(secs=%d) = %v, want %v", c.secs, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedactedMasksSecret(t *testing.T) {
|
|
c := Default()
|
|
c.Proxmox.Token = "felhom-agent@pve!agent=b6547d9d-08ec-4f22-beb8-a551dc2cd69d"
|
|
got := c.Redacted().Proxmox.Token
|
|
if strings.Contains(got, "b6547d9d") {
|
|
t.Fatalf("secret leaked in redacted token: %q", got)
|
|
}
|
|
if !strings.HasPrefix(got, "felhom-agent@pve!agent=") {
|
|
t.Errorf("redacted token lost its public prefix: %q", got)
|
|
}
|
|
// The original must be untouched (Redacted returns a copy).
|
|
if !strings.Contains(c.Proxmox.Token, "b6547d9d") {
|
|
t.Errorf("Redacted mutated the original config")
|
|
}
|
|
}
|
|
|
|
func TestValidate(t *testing.T) {
|
|
c := Default()
|
|
c.Proxmox.Node = "demo-felhom"
|
|
c.Proxmox.Token = "felhom-agent@pve!agent=secret"
|
|
if err := c.Validate(); err != nil {
|
|
t.Fatalf("valid config rejected: %v", err)
|
|
}
|
|
c.Proxmox.Token = "no-bang-no-eq"
|
|
if err := c.Validate(); err == nil {
|
|
t.Errorf("malformed token accepted")
|
|
}
|
|
}
|
|
|
|
func TestRedactedMasksHubKey(t *testing.T) {
|
|
c := Default()
|
|
c.Hub.APIKey = "hub-secret-abcdef"
|
|
if got := c.Redacted().Hub.APIKey; got == "hub-secret-abcdef" || got == "" {
|
|
t.Fatalf("hub key not masked: %q", got)
|
|
}
|
|
if !strings.Contains(c.Hub.APIKey, "abcdef") {
|
|
t.Error("Redacted mutated the original hub key")
|
|
}
|
|
}
|
|
|
|
func TestHubConfigValidate(t *testing.T) {
|
|
base := HubConfig{URL: "https://hub.felhom.eu", HostID: "h1", APIKey: "k"}
|
|
if err := base.Validate(); err != nil {
|
|
t.Fatalf("valid hub config rejected: %v", err)
|
|
}
|
|
bad := []HubConfig{
|
|
{HostID: "h", APIKey: "k"}, // no URL
|
|
{URL: "https://x", APIKey: "k"}, // no host
|
|
{URL: "https://x", HostID: "h"}, // no key
|
|
{URL: "http://hub.felhom.eu", HostID: "h", APIKey: "k"}, // http non-loopback
|
|
{URL: "ftp://x", HostID: "h", APIKey: "k"}, // bad scheme
|
|
}
|
|
for i, h := range bad {
|
|
if err := h.Validate(); err == nil {
|
|
t.Errorf("case %d: expected validation error for %+v", i, h)
|
|
}
|
|
}
|
|
// http is allowed for loopback (tests).
|
|
if err := (HubConfig{URL: "http://127.0.0.1:8443", HostID: "h", APIKey: "k"}).Validate(); err != nil {
|
|
t.Errorf("http loopback should be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHubEnvOverlayAndDefaults(t *testing.T) {
|
|
t.Setenv("FELHOM_AGENT_HUB_URL", "https://hub.example")
|
|
t.Setenv("FELHOM_AGENT_HUB_HOST_ID", "env-host")
|
|
t.Setenv("FELHOM_AGENT_HUB_API_KEY", "env-key")
|
|
t.Setenv("FELHOM_AGENT_HUB_POLL_SECONDS", "120")
|
|
cfg, err := Load("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Hub.URL != "https://hub.example" || cfg.Hub.HostID != "env-host" || cfg.Hub.APIKey != "env-key" {
|
|
t.Errorf("hub env overlay failed: %+v", cfg.Hub)
|
|
}
|
|
if cfg.Hub.PollSeconds != 120 {
|
|
t.Errorf("poll seconds = %d, want 120", cfg.Hub.PollSeconds)
|
|
}
|
|
// withDefaults fills zero timeout.
|
|
if (HubConfig{}).WithDefaults().TimeoutSeconds != 30 {
|
|
t.Error("WithDefaults should set TimeoutSeconds=30")
|
|
}
|
|
}
|
|
|
|
func TestLoadFileThenEnvOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "agent.json")
|
|
if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"file-node","token":"u@pve!t=filesecret"}}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FELHOM_AGENT_PROXMOX_NODE", "env-node")
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Proxmox.Node != "env-node" {
|
|
t.Errorf("env did not override node: %q", cfg.Proxmox.Node)
|
|
}
|
|
if cfg.Proxmox.Token != "u@pve!t=filesecret" {
|
|
t.Errorf("token from file lost: %q", cfg.Proxmox.Token)
|
|
}
|
|
if cfg.Proxmox.Endpoint != "https://127.0.0.1:8006" {
|
|
t.Errorf("default endpoint lost: %q", cfg.Proxmox.Endpoint)
|
|
}
|
|
}
|
|
|
|
// CAMPAIGN-3 Part 6: deployment_mode gates node self-heal, and it is FAIL-SAFE to byo — absent or any
|
|
// unknown value is byo, ONLY the exact "appliance" unlocks the remedy.
|
|
func TestIsAppliance_FailSafeToByo(t *testing.T) {
|
|
cases := []struct {
|
|
mode string
|
|
want bool
|
|
}{
|
|
{"appliance", true},
|
|
{"byo", false},
|
|
{"", false}, // absent field → byo (fail-safe)
|
|
{"Appliance", false}, // case-sensitive — a typo must not unlock the remedy
|
|
{"garbage", false},
|
|
}
|
|
for _, c := range cases {
|
|
cfg := &Config{DeploymentMode: c.mode}
|
|
if got := cfg.IsAppliance(); got != c.want {
|
|
t.Errorf("IsAppliance(mode=%q) = %t, want %t", c.mode, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The env overlay can set deployment_mode (FELHOM_AGENT_DEPLOYMENT_MODE).
|
|
func TestDeploymentModeEnvOverlay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "agent.json")
|
|
if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"n","token":"u@pve!t=s"},"deployment_mode":"byo"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FELHOM_AGENT_DEPLOYMENT_MODE", "appliance")
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !cfg.IsAppliance() {
|
|
t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode)
|
|
}
|
|
}
|
|
|
|
// R-50: the island NIC fields are all-or-nothing and the guest addr must be a CIDR. A half-set or
|
|
// malformed island must fail at config load (a botched install) rather than silently fall back to
|
|
// LAN-only, which would leave a guest with an island bind and no island NIC — the exact silent break
|
|
// R-50 exists to kill. Covers LocalAPIConfig.Validate + IslandEnabled.
|
|
func TestLocalAPIConfig_IslandValidation(t *testing.T) {
|
|
base := LocalAPIConfig{Enable: true, ListenAddr: "169.254.253.1:8443"}
|
|
|
|
// both empty → fine (pre-R-50 default), IslandEnabled false
|
|
if err := base.Validate(); err != nil {
|
|
t.Errorf("no island config must validate: %v", err)
|
|
}
|
|
if base.IslandEnabled() {
|
|
t.Errorf("IslandEnabled must be false when unset")
|
|
}
|
|
// both set, valid CIDR → fine, IslandEnabled true
|
|
ok := base
|
|
ok.IslandBridge, ok.IslandGuestAddr = "vmbr9", "169.254.253.2/30"
|
|
if err := ok.Validate(); err != nil {
|
|
t.Errorf("valid island config must validate: %v", err)
|
|
}
|
|
if !ok.IslandEnabled() {
|
|
t.Errorf("IslandEnabled must be true when both set")
|
|
}
|
|
// bridge only → rejected (all-or-nothing)
|
|
half := base
|
|
half.IslandBridge = "vmbr9"
|
|
if err := half.Validate(); err == nil {
|
|
t.Errorf("half-set island (bridge only) must be rejected")
|
|
}
|
|
// guest addr only → rejected
|
|
half2 := base
|
|
half2.IslandGuestAddr = "169.254.253.2/30"
|
|
if err := half2.Validate(); err == nil {
|
|
t.Errorf("half-set island (guest addr only) must be rejected")
|
|
}
|
|
// both set but guest addr is not a CIDR → rejected
|
|
bad := base
|
|
bad.IslandBridge, bad.IslandGuestAddr = "vmbr9", "169.254.253.2" // missing /30
|
|
if err := bad.Validate(); err == nil {
|
|
t.Errorf("island guest addr without a CIDR mask must be rejected")
|
|
}
|
|
}
|