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