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>
239 lines
5.3 KiB
Go
239 lines
5.3 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|