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:
238
cloud-init/parser_test.go
Normal file
238
cloud-init/parser_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user