feat: add cloud-init Go parser (Phase 2)

Implement a lightweight cloud-init system for first-boot configuration:
- Go parser for YAML config (hostname, network, KubeSolo settings)
- Static/DHCP network modes with DNS override
- KubeSolo extra flags and API server SAN configuration
- Portainer Edge Agent and air-gapped deployment support
- New init stage 45-cloud-init.sh runs before network/hostname stages
- Stages 50/60 skip gracefully when cloud-init has already applied
- Build script compiles static Linux/amd64 binary (~2.7 MB)
- 17 unit tests covering parsing, validation, and example files
- Full documentation at docs/cloud-init.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 10:39:05 -06:00
parent e372df578b
commit d900fa920e
17 changed files with 1217 additions and 12 deletions

132
cloud-init/cmd/main.go Normal file
View File

@@ -0,0 +1,132 @@
// kubesolo-cloudinit is a lightweight cloud-init parser for KubeSolo OS.
//
// It reads a YAML configuration file and applies hostname, network, and
// KubeSolo settings during the init sequence. Designed to run as a static
// binary on BusyBox-based systems.
//
// Usage:
//
// kubesolo-cloudinit apply <config.yaml>
// kubesolo-cloudinit validate <config.yaml>
// kubesolo-cloudinit dump <config.yaml>
package main
import (
"encoding/json"
"fmt"
"log/slog"
"os"
cloudinit "github.com/portainer/kubesolo-os/cloud-init"
)
const (
defaultConfigPath = "/mnt/data/etc-kubesolo/cloud-init.yaml"
persistDataDir = "/mnt/data"
configDir = "/etc/kubesolo"
)
func main() {
// Set up structured logging to stderr (captured by init)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
cmd := os.Args[1]
// Determine config path
configPath := defaultConfigPath
if len(os.Args) >= 3 {
configPath = os.Args[2]
}
switch cmd {
case "apply":
if err := cmdApply(configPath); err != nil {
slog.Error("cloud-init apply failed", "error", err)
os.Exit(1)
}
case "validate":
if err := cmdValidate(configPath); err != nil {
fmt.Fprintf(os.Stderr, "validation failed: %s\n", err)
os.Exit(1)
}
fmt.Println("OK")
case "dump":
if err := cmdDump(configPath); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
usage()
os.Exit(1)
}
}
func cmdApply(configPath string) error {
slog.Info("applying cloud-init", "config", configPath)
cfg, err := cloudinit.Parse(configPath)
if err != nil {
return err
}
// 1. Apply hostname
if err := cloudinit.ApplyHostname(cfg); err != nil {
return fmt.Errorf("hostname: %w", err)
}
// 2. Apply network configuration
if err := cloudinit.ApplyNetwork(cfg); err != nil {
return fmt.Errorf("network: %w", err)
}
// 3. Apply KubeSolo settings
if err := cloudinit.ApplyKubeSolo(cfg, configDir); err != nil {
return fmt.Errorf("kubesolo config: %w", err)
}
// 4. Save persistent configs for next boot
if err := cloudinit.SaveHostname(cfg, persistDataDir+"/etc-kubesolo"); err != nil {
slog.Warn("failed to save hostname", "error", err)
}
if err := cloudinit.SaveNetworkConfig(cfg, persistDataDir+"/network"); err != nil {
slog.Warn("failed to save network config", "error", err)
}
slog.Info("cloud-init applied successfully")
return nil
}
func cmdValidate(configPath string) error {
_, err := cloudinit.Parse(configPath)
return err
}
func cmdDump(configPath string) error {
cfg, err := cloudinit.Parse(configPath)
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(cfg)
}
func usage() {
fmt.Fprintf(os.Stderr, `Usage: kubesolo-cloudinit <command> [config.yaml]
Commands:
apply Parse and apply cloud-init configuration
validate Check config file for errors
dump Parse and print config as JSON
If config path is omitted, defaults to %s
`, defaultConfigPath)
}

62
cloud-init/config.go Normal file
View File

@@ -0,0 +1,62 @@
// Package cloudinit implements a lightweight cloud-init parser for KubeSolo OS.
//
// It reads a simplified cloud-init YAML config and applies:
// - hostname
// - network configuration (static IP or DHCP)
// - KubeSolo extra flags and settings
// - NTP servers
//
// The config file is typically at /mnt/data/etc-kubesolo/cloud-init.yaml
// or specified via kubesolo.cloudinit= boot parameter.
package cloudinit
// Config is the top-level cloud-init configuration.
type Config struct {
Hostname string `yaml:"hostname"`
Network NetworkConfig `yaml:"network"`
KubeSolo KubeSoloConfig `yaml:"kubesolo"`
NTP NTPConfig `yaml:"ntp"`
Airgap AirgapConfig `yaml:"airgap"`
Portainer PortainerConfig `yaml:"portainer"`
}
// NetworkConfig defines network settings.
type NetworkConfig struct {
Mode string `yaml:"mode"` // "dhcp" or "static"
Interface string `yaml:"interface"` // e.g. "eth0" (auto-detected if empty)
Address string `yaml:"address"` // CIDR notation, e.g. "192.168.1.100/24"
Gateway string `yaml:"gateway"` // e.g. "192.168.1.1"
DNS []string `yaml:"dns"` // nameservers
}
// KubeSoloConfig defines KubeSolo-specific settings.
type KubeSoloConfig struct {
ExtraFlags string `yaml:"extra-flags"`
LocalStorage *bool `yaml:"local-storage"`
ExtraSANs []string `yaml:"apiserver-extra-sans"`
}
// NTPConfig defines NTP settings.
type NTPConfig struct {
Servers []string `yaml:"servers"`
}
// AirgapConfig defines air-gapped deployment settings.
type AirgapConfig struct {
ImportImages bool `yaml:"import-images"`
ImagesDir string `yaml:"images-dir"`
}
// PortainerConfig defines Portainer Edge Agent settings.
type PortainerConfig struct {
EdgeAgent EdgeAgentConfig `yaml:"edge-agent"`
}
// EdgeAgentConfig holds Portainer Edge Agent connection details.
type EdgeAgentConfig struct {
Enabled bool `yaml:"enabled"`
EdgeID string `yaml:"edge-id"`
EdgeKey string `yaml:"edge-key"`
PortainerURL string `yaml:"portainer-url"`
Image string `yaml:"image"`
}

5
cloud-init/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/portainer/kubesolo-os/cloud-init
go 1.25.5
require gopkg.in/yaml.v3 v3.0.1

4
cloud-init/go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

80
cloud-init/hostname.go Normal file
View File

@@ -0,0 +1,80 @@
package cloudinit
import (
"fmt"
"log/slog"
"os"
"strings"
)
// ApplyHostname sets the system hostname and updates /etc/hostname and /etc/hosts.
func ApplyHostname(cfg *Config) error {
hostname := cfg.Hostname
if hostname == "" {
slog.Info("no hostname in cloud-init, skipping")
return nil
}
// Set the running hostname
if err := os.WriteFile("/proc/sys/kernel/hostname", []byte(hostname), 0o644); err != nil {
// Fallback: use the hostname command
if err := run("hostname", hostname); err != nil {
return fmt.Errorf("setting hostname: %w", err)
}
}
// Write /etc/hostname
if err := os.WriteFile("/etc/hostname", []byte(hostname+"\n"), 0o644); err != nil {
return fmt.Errorf("writing /etc/hostname: %w", err)
}
// Ensure hostname is in /etc/hosts
if err := ensureHostsEntry(hostname); err != nil {
return fmt.Errorf("updating /etc/hosts: %w", err)
}
slog.Info("hostname set", "hostname", hostname)
return nil
}
// SaveHostname writes the hostname to the persistent data partition so it
// survives reboots (even without cloud-init on next boot).
func SaveHostname(cfg *Config, destDir string) error {
if cfg.Hostname == "" {
return nil
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("creating hostname dir: %w", err)
}
dest := destDir + "/hostname"
if err := os.WriteFile(dest, []byte(cfg.Hostname+"\n"), 0o644); err != nil {
return fmt.Errorf("writing persistent hostname: %w", err)
}
slog.Info("hostname saved", "path", dest)
return nil
}
func ensureHostsEntry(hostname string) error {
data, err := os.ReadFile("/etc/hosts")
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(data)
entry := "127.0.0.1 " + hostname
// Check if already present
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, hostname) {
return nil
}
}
// Append
if !strings.HasSuffix(content, "\n") && content != "" {
content += "\n"
}
content += entry + "\n"
return os.WriteFile("/etc/hosts", []byte(content), 0o644)
}

79
cloud-init/kubesolo.go Normal file
View File

@@ -0,0 +1,79 @@
package cloudinit
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
// ApplyKubeSolo writes KubeSolo configuration files based on cloud-init config.
// These files are read by init stage 90-kubesolo.sh when building the
// KubeSolo command line.
func ApplyKubeSolo(cfg *Config, configDir string) error {
if err := os.MkdirAll(configDir, 0o755); err != nil {
return fmt.Errorf("creating config dir %s: %w", configDir, err)
}
// Write extra flags file (consumed by 90-kubesolo.sh)
flags := buildExtraFlags(cfg)
if flags != "" {
flagsPath := filepath.Join(configDir, "extra-flags")
if err := os.WriteFile(flagsPath, []byte(flags+"\n"), 0o644); err != nil {
return fmt.Errorf("writing extra-flags: %w", err)
}
slog.Info("wrote KubeSolo extra flags", "path", flagsPath, "flags", flags)
}
// Write config.yaml for KubeSolo if we have settings beyond defaults
if err := writeKubeSoloConfig(cfg, configDir); err != nil {
return err
}
return nil
}
func buildExtraFlags(cfg *Config) string {
var parts []string
if cfg.KubeSolo.ExtraFlags != "" {
parts = append(parts, cfg.KubeSolo.ExtraFlags)
}
// Add extra SANs from cloud-init
for _, san := range cfg.KubeSolo.ExtraSANs {
parts = append(parts, "--apiserver-extra-sans", san)
}
return strings.Join(parts, " ")
}
func writeKubeSoloConfig(cfg *Config, configDir string) error {
var lines []string
lines = append(lines, "# Generated by KubeSolo OS cloud-init")
lines = append(lines, "data-dir: /var/lib/kubesolo")
if cfg.KubeSolo.LocalStorage != nil {
if *cfg.KubeSolo.LocalStorage {
lines = append(lines, "local-storage: true")
} else {
lines = append(lines, "local-storage: false")
}
} else {
lines = append(lines, "local-storage: true")
}
lines = append(lines, "bind-address: 0.0.0.0")
lines = append(lines, "cluster-cidr: 10.42.0.0/16")
lines = append(lines, "service-cidr: 10.43.0.0/16")
dest := filepath.Join(configDir, "config.yaml")
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
return fmt.Errorf("writing config.yaml: %w", err)
}
slog.Info("wrote KubeSolo config", "path", dest)
return nil
}

118
cloud-init/kubesolo_test.go Normal file
View File

@@ -0,0 +1,118 @@
package cloudinit
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildExtraFlags(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{
name: "empty",
cfg: Config{},
want: "",
},
{
name: "extra flags only",
cfg: Config{
KubeSolo: KubeSoloConfig{ExtraFlags: "--disable traefik"},
},
want: "--disable traefik",
},
{
name: "extra sans only",
cfg: Config{
KubeSolo: KubeSoloConfig{
ExtraSANs: []string{"node.local", "192.168.1.100"},
},
},
want: "--apiserver-extra-sans node.local --apiserver-extra-sans 192.168.1.100",
},
{
name: "flags and sans",
cfg: Config{
KubeSolo: KubeSoloConfig{
ExtraFlags: "--disable servicelb",
ExtraSANs: []string{"edge.local"},
},
},
want: "--disable servicelb --apiserver-extra-sans edge.local",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildExtraFlags(&tt.cfg)
if got != tt.want {
t.Errorf("buildExtraFlags() = %q, want %q", got, tt.want)
}
})
}
}
func TestApplyKubeSolo(t *testing.T) {
dir := t.TempDir()
tr := true
cfg := &Config{
KubeSolo: KubeSoloConfig{
ExtraFlags: "--disable traefik",
LocalStorage: &tr,
ExtraSANs: []string{"test.local"},
},
}
if err := ApplyKubeSolo(cfg, dir); err != nil {
t.Fatalf("ApplyKubeSolo error: %v", err)
}
// Check extra-flags file
flagsData, err := os.ReadFile(filepath.Join(dir, "extra-flags"))
if err != nil {
t.Fatalf("reading extra-flags: %v", err)
}
flags := strings.TrimSpace(string(flagsData))
if !strings.Contains(flags, "--disable traefik") {
t.Errorf("extra-flags missing '--disable traefik': %q", flags)
}
if !strings.Contains(flags, "--apiserver-extra-sans test.local") {
t.Errorf("extra-flags missing SANs: %q", flags)
}
// Check config.yaml
configData, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
if err != nil {
t.Fatalf("reading config.yaml: %v", err)
}
config := string(configData)
if !strings.Contains(config, "local-storage: true") {
t.Errorf("config.yaml missing local-storage: %q", config)
}
if !strings.Contains(config, "data-dir: /var/lib/kubesolo") {
t.Errorf("config.yaml missing data-dir: %q", config)
}
}
func TestApplyKubeSoloNoFlags(t *testing.T) {
dir := t.TempDir()
cfg := &Config{}
if err := ApplyKubeSolo(cfg, dir); err != nil {
t.Fatalf("ApplyKubeSolo error: %v", err)
}
// extra-flags should not exist when empty
if _, err := os.Stat(filepath.Join(dir, "extra-flags")); !os.IsNotExist(err) {
t.Error("extra-flags file should not exist when no flags configured")
}
// config.yaml should still be created with defaults
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
t.Error("config.yaml should be created even with empty config")
}
}

174
cloud-init/network.go Normal file
View File

@@ -0,0 +1,174 @@
package cloudinit
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ApplyNetwork configures the network interface based on cloud-init config.
// For static mode, it sets the IP, gateway, and DNS directly.
// For DHCP mode, it runs udhcpc on the target interface.
func ApplyNetwork(cfg *Config) error {
iface := cfg.Network.Interface
if iface == "" {
var err error
iface, err = detectPrimaryInterface()
if err != nil {
return fmt.Errorf("detecting primary interface: %w", err)
}
}
slog.Info("configuring network", "interface", iface, "mode", cfg.Network.Mode)
// Bring up the interface
if err := run("ip", "link", "set", iface, "up"); err != nil {
return fmt.Errorf("bringing up %s: %w", iface, err)
}
switch cfg.Network.Mode {
case "static":
return applyStatic(iface, cfg)
case "dhcp", "":
return applyDHCP(iface, cfg)
default:
return fmt.Errorf("unknown network mode: %s", cfg.Network.Mode)
}
}
func applyStatic(iface string, cfg *Config) error {
// Set IP address
if err := run("ip", "addr", "add", cfg.Network.Address, "dev", iface); err != nil {
return fmt.Errorf("setting address %s on %s: %w", cfg.Network.Address, iface, err)
}
// Set default gateway
if err := run("ip", "route", "add", "default", "via", cfg.Network.Gateway, "dev", iface); err != nil {
return fmt.Errorf("setting gateway %s: %w", cfg.Network.Gateway, err)
}
// Write DNS configuration
if len(cfg.Network.DNS) > 0 {
if err := writeDNS(cfg.Network.DNS); err != nil {
return fmt.Errorf("writing DNS config: %w", err)
}
}
slog.Info("static network configured",
"interface", iface,
"address", cfg.Network.Address,
"gateway", cfg.Network.Gateway,
)
return nil
}
func applyDHCP(iface string, cfg *Config) error {
// Try udhcpc (BusyBox), then dhcpcd
if path, err := exec.LookPath("udhcpc"); err == nil {
args := []string{"-i", iface, "-s", "/usr/share/udhcpc/default.script",
"-t", "10", "-T", "3", "-A", "5", "-b", "-q"}
if err := run(path, args...); err != nil {
return fmt.Errorf("udhcpc on %s: %w", iface, err)
}
} else if path, err := exec.LookPath("dhcpcd"); err == nil {
if err := run(path, iface); err != nil {
return fmt.Errorf("dhcpcd on %s: %w", iface, err)
}
} else {
return fmt.Errorf("no DHCP client available (need udhcpc or dhcpcd)")
}
// Override DNS if specified in config
if len(cfg.Network.DNS) > 0 {
if err := writeDNS(cfg.Network.DNS); err != nil {
return fmt.Errorf("writing DNS config: %w", err)
}
}
slog.Info("DHCP network configured", "interface", iface)
return nil
}
// SaveNetworkConfig writes a shell script that restores the current network
// config on subsequent boots (before cloud-init runs).
func SaveNetworkConfig(cfg *Config, destDir string) error {
iface := cfg.Network.Interface
if iface == "" {
var err error
iface, err = detectPrimaryInterface()
if err != nil {
return err
}
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("creating network config dir: %w", err)
}
dest := filepath.Join(destDir, "interfaces.sh")
var sb strings.Builder
sb.WriteString("#!/bin/sh\n")
sb.WriteString("# Auto-generated by KubeSolo OS cloud-init\n")
sb.WriteString(fmt.Sprintf("ip link set %s up\n", iface))
switch cfg.Network.Mode {
case "static":
sb.WriteString(fmt.Sprintf("ip addr add %s dev %s\n", cfg.Network.Address, iface))
sb.WriteString(fmt.Sprintf("ip route add default via %s dev %s\n", cfg.Network.Gateway, iface))
if len(cfg.Network.DNS) > 0 {
sb.WriteString(": > /etc/resolv.conf\n")
for _, ns := range cfg.Network.DNS {
sb.WriteString(fmt.Sprintf("echo 'nameserver %s' >> /etc/resolv.conf\n", ns))
}
}
case "dhcp", "":
sb.WriteString("udhcpc -i " + iface + " -s /usr/share/udhcpc/default.script -t 10 -T 3 -A 5 -b -q 2>/dev/null\n")
}
if err := os.WriteFile(dest, []byte(sb.String()), 0o755); err != nil {
return fmt.Errorf("writing network config: %w", err)
}
slog.Info("network config saved", "path", dest)
return nil
}
func writeDNS(servers []string) error {
var sb strings.Builder
for _, ns := range servers {
sb.WriteString("nameserver " + ns + "\n")
}
return os.WriteFile("/etc/resolv.conf", []byte(sb.String()), 0o644)
}
func detectPrimaryInterface() (string, error) {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return "", fmt.Errorf("reading /sys/class/net: %w", err)
}
for _, e := range entries {
name := e.Name()
switch {
case name == "lo",
strings.HasPrefix(name, "docker"),
strings.HasPrefix(name, "veth"),
strings.HasPrefix(name, "br"),
strings.HasPrefix(name, "cni"),
strings.HasPrefix(name, "flannel"),
strings.HasPrefix(name, "cali"):
continue
}
return name, nil
}
return "", fmt.Errorf("no suitable network interface found")
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

54
cloud-init/parser.go Normal file
View File

@@ -0,0 +1,54 @@
package cloudinit
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Parse reads a cloud-init YAML file and returns the parsed config.
func Parse(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading cloud-init file %s: %w", path, err)
}
return ParseBytes(data)
}
// ParseBytes parses cloud-init YAML from a byte slice.
func ParseBytes(data []byte) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing cloud-init YAML: %w", err)
}
if err := validate(&cfg); err != nil {
return nil, fmt.Errorf("validating cloud-init config: %w", err)
}
// Apply defaults
if cfg.Network.Mode == "" {
cfg.Network.Mode = "dhcp"
}
return &cfg, nil
}
func validate(cfg *Config) error {
switch cfg.Network.Mode {
case "", "dhcp":
// valid
case "static":
if cfg.Network.Address == "" {
return fmt.Errorf("static network mode requires 'address' field")
}
if cfg.Network.Gateway == "" {
return fmt.Errorf("static network mode requires 'gateway' field")
}
default:
return fmt.Errorf("unknown network mode: %q (expected 'dhcp' or 'static')", cfg.Network.Mode)
}
return nil
}

238
cloud-init/parser_test.go Normal file
View File

@@ -0,0 +1,238 @@
package cloudinit
import (
"testing"
)
func TestParseDHCP(t *testing.T) {
yaml := []byte(`
hostname: test-node
network:
mode: dhcp
kubesolo:
local-storage: true
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Hostname != "test-node" {
t.Errorf("hostname = %q, want %q", cfg.Hostname, "test-node")
}
if cfg.Network.Mode != "dhcp" {
t.Errorf("network.mode = %q, want %q", cfg.Network.Mode, "dhcp")
}
}
func TestParseStatic(t *testing.T) {
yaml := []byte(`
hostname: edge-01
network:
mode: static
interface: eth0
address: 192.168.1.100/24
gateway: 192.168.1.1
dns:
- 8.8.8.8
- 8.8.4.4
kubesolo:
extra-flags: "--disable traefik"
local-storage: true
apiserver-extra-sans:
- edge-01.local
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Hostname != "edge-01" {
t.Errorf("hostname = %q, want %q", cfg.Hostname, "edge-01")
}
if cfg.Network.Mode != "static" {
t.Errorf("network.mode = %q, want %q", cfg.Network.Mode, "static")
}
if cfg.Network.Address != "192.168.1.100/24" {
t.Errorf("network.address = %q, want %q", cfg.Network.Address, "192.168.1.100/24")
}
if cfg.Network.Gateway != "192.168.1.1" {
t.Errorf("network.gateway = %q, want %q", cfg.Network.Gateway, "192.168.1.1")
}
if len(cfg.Network.DNS) != 2 {
t.Fatalf("dns count = %d, want 2", len(cfg.Network.DNS))
}
if cfg.Network.DNS[0] != "8.8.8.8" {
t.Errorf("dns[0] = %q, want %q", cfg.Network.DNS[0], "8.8.8.8")
}
if cfg.KubeSolo.ExtraFlags != "--disable traefik" {
t.Errorf("extra-flags = %q, want %q", cfg.KubeSolo.ExtraFlags, "--disable traefik")
}
if len(cfg.KubeSolo.ExtraSANs) != 1 || cfg.KubeSolo.ExtraSANs[0] != "edge-01.local" {
t.Errorf("extra-sans = %v, want [edge-01.local]", cfg.KubeSolo.ExtraSANs)
}
}
func TestParseDefaultMode(t *testing.T) {
yaml := []byte(`
hostname: default-node
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Network.Mode != "dhcp" {
t.Errorf("network.mode = %q, want %q (default)", cfg.Network.Mode, "dhcp")
}
}
func TestParseStaticMissingAddress(t *testing.T) {
yaml := []byte(`
network:
mode: static
gateway: 192.168.1.1
`)
_, err := ParseBytes(yaml)
if err == nil {
t.Fatal("expected error for static mode without address")
}
}
func TestParseStaticMissingGateway(t *testing.T) {
yaml := []byte(`
network:
mode: static
address: 192.168.1.100/24
`)
_, err := ParseBytes(yaml)
if err == nil {
t.Fatal("expected error for static mode without gateway")
}
}
func TestParseUnknownMode(t *testing.T) {
yaml := []byte(`
network:
mode: ppp
`)
_, err := ParseBytes(yaml)
if err == nil {
t.Fatal("expected error for unknown network mode")
}
}
func TestParseAirgap(t *testing.T) {
yaml := []byte(`
hostname: airgap-node
network:
mode: static
address: 10.0.0.50/24
gateway: 10.0.0.1
airgap:
import-images: true
images-dir: /mnt/data/images
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.Airgap.ImportImages {
t.Error("airgap.import-images should be true")
}
if cfg.Airgap.ImagesDir != "/mnt/data/images" {
t.Errorf("airgap.images-dir = %q, want %q", cfg.Airgap.ImagesDir, "/mnt/data/images")
}
}
func TestParsePortainer(t *testing.T) {
yaml := []byte(`
hostname: edge-node
network:
mode: dhcp
portainer:
edge-agent:
enabled: true
edge-id: test-id
edge-key: test-key
portainer-url: https://portainer.example.com
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.Portainer.EdgeAgent.Enabled {
t.Error("portainer.edge-agent.enabled should be true")
}
if cfg.Portainer.EdgeAgent.EdgeID != "test-id" {
t.Errorf("edge-id = %q, want %q", cfg.Portainer.EdgeAgent.EdgeID, "test-id")
}
if cfg.Portainer.EdgeAgent.PortainerURL != "https://portainer.example.com" {
t.Errorf("portainer-url = %q", cfg.Portainer.EdgeAgent.PortainerURL)
}
}
func TestParseNTP(t *testing.T) {
yaml := []byte(`
hostname: ntp-node
ntp:
servers:
- pool.ntp.org
- time.google.com
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.NTP.Servers) != 2 {
t.Fatalf("ntp.servers count = %d, want 2", len(cfg.NTP.Servers))
}
if cfg.NTP.Servers[0] != "pool.ntp.org" {
t.Errorf("ntp.servers[0] = %q, want %q", cfg.NTP.Servers[0], "pool.ntp.org")
}
}
func TestParseBoolPointer(t *testing.T) {
yaml := []byte(`
kubesolo:
local-storage: false
`)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.KubeSolo.LocalStorage == nil {
t.Fatal("local-storage should not be nil")
}
if *cfg.KubeSolo.LocalStorage {
t.Error("local-storage should be false")
}
}
func TestParseEmptyConfig(t *testing.T) {
yaml := []byte(``)
cfg, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Network.Mode != "dhcp" {
t.Errorf("empty config should default to dhcp, got %q", cfg.Network.Mode)
}
}
func TestParseExampleFiles(t *testing.T) {
examples := []string{
"examples/dhcp.yaml",
"examples/static-ip.yaml",
"examples/portainer-edge.yaml",
"examples/airgapped.yaml",
}
for _, path := range examples {
t.Run(path, func(t *testing.T) {
_, err := Parse(path)
if err != nil {
t.Errorf("failed to parse %s: %v", path, err)
}
})
}
}