Files
kubesolo-os/cloud-init/network.go
Adolfo Delorenzo d900fa920e 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>
2026-02-11 10:39:05 -06:00

175 lines
4.9 KiB
Go

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()
}