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:
16
Makefile
16
Makefile
@@ -1,5 +1,6 @@
|
|||||||
.PHONY: all fetch rootfs initramfs iso disk-image \
|
.PHONY: all fetch build-cloudinit rootfs initramfs iso disk-image \
|
||||||
test-boot test-k8s test-persistence test-deploy test-storage test-all \
|
test-boot test-k8s test-persistence test-deploy test-storage test-all \
|
||||||
|
test-cloudinit \
|
||||||
dev-vm dev-vm-shell quick docker-build shellcheck \
|
dev-vm dev-vm-shell quick docker-build shellcheck \
|
||||||
kernel-audit clean distclean help
|
kernel-audit clean distclean help
|
||||||
|
|
||||||
@@ -27,7 +28,11 @@ fetch:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Build stages
|
# Build stages
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
rootfs: fetch
|
build-cloudinit:
|
||||||
|
@echo "==> Building cloud-init binary..."
|
||||||
|
$(BUILD_DIR)/scripts/build-cloudinit.sh
|
||||||
|
|
||||||
|
rootfs: fetch build-cloudinit
|
||||||
@echo "==> Preparing rootfs..."
|
@echo "==> Preparing rootfs..."
|
||||||
$(BUILD_DIR)/scripts/extract-core.sh
|
$(BUILD_DIR)/scripts/extract-core.sh
|
||||||
$(BUILD_DIR)/scripts/inject-kubesolo.sh
|
$(BUILD_DIR)/scripts/inject-kubesolo.sh
|
||||||
@@ -78,6 +83,11 @@ test-storage: iso
|
|||||||
|
|
||||||
test-all: test-boot test-k8s test-persistence
|
test-all: test-boot test-k8s test-persistence
|
||||||
|
|
||||||
|
# Cloud-init Go tests
|
||||||
|
test-cloudinit:
|
||||||
|
@echo "==> Testing cloud-init parser..."
|
||||||
|
cd cloud-init && go test ./... -v -count=1
|
||||||
|
|
||||||
# Full integration test suite (requires more time)
|
# Full integration test suite (requires more time)
|
||||||
test-integration: test-k8s test-deploy test-storage
|
test-integration: test-k8s test-deploy test-storage
|
||||||
|
|
||||||
@@ -148,6 +158,7 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Build targets:"
|
@echo "Build targets:"
|
||||||
@echo " make fetch Download Tiny Core ISO, KubeSolo, dependencies"
|
@echo " make fetch Download Tiny Core ISO, KubeSolo, dependencies"
|
||||||
|
@echo " make build-cloudinit Build cloud-init Go binary"
|
||||||
@echo " make rootfs Extract + prepare rootfs with KubeSolo"
|
@echo " make rootfs Extract + prepare rootfs with KubeSolo"
|
||||||
@echo " make initramfs Repack rootfs into kubesolo-os.gz"
|
@echo " make initramfs Repack rootfs into kubesolo-os.gz"
|
||||||
@echo " make iso Create bootable ISO (default target)"
|
@echo " make iso Create bootable ISO (default target)"
|
||||||
@@ -161,6 +172,7 @@ help:
|
|||||||
@echo " make test-persist Reboot disk image, verify state persists"
|
@echo " make test-persist Reboot disk image, verify state persists"
|
||||||
@echo " make test-deploy Deploy nginx pod, verify Running"
|
@echo " make test-deploy Deploy nginx pod, verify Running"
|
||||||
@echo " make test-storage Test PVC with local-path provisioner"
|
@echo " make test-storage Test PVC with local-path provisioner"
|
||||||
|
@echo " make test-cloudinit Run cloud-init Go unit tests"
|
||||||
@echo " make test-all Run core tests (boot + k8s + persistence)"
|
@echo " make test-all Run core tests (boot + k8s + persistence)"
|
||||||
@echo " make test-integ Run full integration suite"
|
@echo " make test-integ Run full integration suite"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
39
build/scripts/build-cloudinit.sh
Executable file
39
build/scripts/build-cloudinit.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# build-cloudinit.sh — Compile the cloud-init binary as a static Linux binary
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
CACHE_DIR="${CACHE_DIR:-$PROJECT_ROOT/build/cache}"
|
||||||
|
CLOUDINIT_SRC="$PROJECT_ROOT/cloud-init"
|
||||||
|
|
||||||
|
OUTPUT="$CACHE_DIR/kubesolo-cloudinit"
|
||||||
|
|
||||||
|
echo "==> Building cloud-init binary..."
|
||||||
|
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Go is not installed. Install Go 1.22+ to build cloud-init."
|
||||||
|
echo " https://go.dev/dl/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
|
||||||
|
cd "$CLOUDINIT_SRC"
|
||||||
|
|
||||||
|
# Run tests first
|
||||||
|
echo " Running tests..."
|
||||||
|
go test ./... -count=1 || {
|
||||||
|
echo "ERROR: Tests failed. Fix tests before building."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build static binary for Linux amd64
|
||||||
|
echo " Compiling (CGO_ENABLED=0 GOOS=linux GOARCH=amd64)..."
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags='-s -w' \
|
||||||
|
-o "$OUTPUT" \
|
||||||
|
./cmd/
|
||||||
|
|
||||||
|
echo " Built: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
|
||||||
|
echo ""
|
||||||
@@ -63,6 +63,16 @@ for lib in network.sh health.sh; do
|
|||||||
[ -f "$src" ] && cp "$src" "$ROOTFS/usr/lib/kubesolo-os/$lib"
|
[ -f "$src" ] && cp "$src" "$ROOTFS/usr/lib/kubesolo-os/$lib"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Cloud-init binary (Go, built separately)
|
||||||
|
CLOUDINIT_BIN="$CACHE_DIR/kubesolo-cloudinit"
|
||||||
|
if [ -f "$CLOUDINIT_BIN" ]; then
|
||||||
|
cp "$CLOUDINIT_BIN" "$ROOTFS/usr/lib/kubesolo-os/kubesolo-cloudinit"
|
||||||
|
chmod +x "$ROOTFS/usr/lib/kubesolo-os/kubesolo-cloudinit"
|
||||||
|
echo " Installed cloud-init binary ($(du -h "$CLOUDINIT_BIN" | cut -f1))"
|
||||||
|
else
|
||||||
|
echo " WARN: Cloud-init binary not found (run 'make build-cloudinit' to build)"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 3. Kernel modules list ---
|
# --- 3. Kernel modules list ---
|
||||||
cp "$PROJECT_ROOT/build/config/modules.list" "$ROOTFS/usr/lib/kubesolo-os/modules.list"
|
cp "$PROJECT_ROOT/build/config/modules.list" "$ROOTFS/usr/lib/kubesolo-os/modules.list"
|
||||||
|
|
||||||
|
|||||||
132
cloud-init/cmd/main.go
Normal file
132
cloud-init/cmd/main.go
Normal 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
62
cloud-init/config.go
Normal 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
5
cloud-init/go.mod
Normal 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
4
cloud-init/go.sum
Normal 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
80
cloud-init/hostname.go
Normal 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
79
cloud-init/kubesolo.go
Normal 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
118
cloud-init/kubesolo_test.go
Normal 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
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()
|
||||||
|
}
|
||||||
54
cloud-init/parser.go
Normal file
54
cloud-init/parser.go
Normal 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
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
156
docs/cloud-init.md
Normal file
156
docs/cloud-init.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# KubeSolo OS — Cloud-Init Configuration
|
||||||
|
|
||||||
|
KubeSolo OS uses a lightweight cloud-init system to configure the node on first boot. The configuration is a YAML file placed on the data partition before the first boot.
|
||||||
|
|
||||||
|
## Configuration File Location
|
||||||
|
|
||||||
|
The cloud-init config is loaded from (in priority order):
|
||||||
|
|
||||||
|
1. Path specified by `kubesolo.cloudinit=<path>` boot parameter
|
||||||
|
2. `/mnt/data/etc-kubesolo/cloud-init.yaml` (default)
|
||||||
|
|
||||||
|
## Boot Sequence Integration
|
||||||
|
|
||||||
|
Cloud-init runs as **init stage 45**, before network (stage 50) and hostname (stage 60). When cloud-init applies successfully, stages 50 and 60 detect this and skip their default behavior.
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 20: Mount persistent storage
|
||||||
|
Stage 30: Load kernel modules
|
||||||
|
Stage 40: Apply sysctl
|
||||||
|
Stage 45: Cloud-init (parse YAML, apply hostname + network + KubeSolo config) <--
|
||||||
|
Stage 50: Network fallback (skipped if cloud-init handled it)
|
||||||
|
Stage 60: Hostname fallback (skipped if cloud-init handled it)
|
||||||
|
Stage 70: Clock sync
|
||||||
|
Stage 80: Containerd prerequisites
|
||||||
|
Stage 90: Start KubeSolo
|
||||||
|
```
|
||||||
|
|
||||||
|
## YAML Schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Hostname for the node
|
||||||
|
hostname: kubesolo-node
|
||||||
|
|
||||||
|
# Network configuration
|
||||||
|
network:
|
||||||
|
mode: dhcp | static # Default: dhcp
|
||||||
|
interface: eth0 # Optional: auto-detected if omitted
|
||||||
|
address: 192.168.1.100/24 # Required for static mode (CIDR notation)
|
||||||
|
gateway: 192.168.1.1 # Required for static mode
|
||||||
|
dns: # Optional: DNS nameservers
|
||||||
|
- 8.8.8.8
|
||||||
|
- 1.1.1.1
|
||||||
|
|
||||||
|
# KubeSolo settings
|
||||||
|
kubesolo:
|
||||||
|
extra-flags: "--disable traefik" # Extra CLI flags for KubeSolo binary
|
||||||
|
local-storage: true # Enable local-path provisioner (default: true)
|
||||||
|
apiserver-extra-sans: # Extra SANs for API server certificate
|
||||||
|
- node.example.com
|
||||||
|
- 10.0.0.50
|
||||||
|
|
||||||
|
# NTP servers (optional)
|
||||||
|
ntp:
|
||||||
|
servers:
|
||||||
|
- pool.ntp.org
|
||||||
|
|
||||||
|
# Air-gapped deployment (optional)
|
||||||
|
airgap:
|
||||||
|
import-images: true # Import container images from data partition
|
||||||
|
images-dir: /mnt/data/images # Directory containing .tar image files
|
||||||
|
|
||||||
|
# Portainer Edge Agent (optional)
|
||||||
|
portainer:
|
||||||
|
edge-agent:
|
||||||
|
enabled: true
|
||||||
|
edge-id: "your-edge-id"
|
||||||
|
edge-key: "your-edge-key"
|
||||||
|
portainer-url: "https://portainer.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Modes
|
||||||
|
|
||||||
|
### DHCP (Default)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network:
|
||||||
|
mode: dhcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses BusyBox `udhcpc` on the first non-virtual interface. Optionally override DNS:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network:
|
||||||
|
mode: dhcp
|
||||||
|
dns:
|
||||||
|
- 10.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static IP
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `address` (CIDR) and `gateway` are required for static mode.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
After applying, cloud-init saves its configuration to the data partition:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `/mnt/data/network/interfaces.sh` | Shell script to restore network config on next boot |
|
||||||
|
| `/mnt/data/etc-kubesolo/hostname` | Saved hostname |
|
||||||
|
| `/etc/kubesolo/extra-flags` | KubeSolo CLI flags |
|
||||||
|
| `/etc/kubesolo/config.yaml` | KubeSolo configuration |
|
||||||
|
|
||||||
|
On subsequent boots, stage 50 (network) sources the saved `interfaces.sh` directly, skipping cloud-init parsing entirely. This is faster and doesn't require the cloud-init binary.
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
The cloud-init binary supports three commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply configuration (run during boot by stage 45)
|
||||||
|
kubesolo-cloudinit apply /path/to/cloud-init.yaml
|
||||||
|
|
||||||
|
# Validate a config file
|
||||||
|
kubesolo-cloudinit validate /path/to/cloud-init.yaml
|
||||||
|
|
||||||
|
# Dump parsed config as JSON (for debugging)
|
||||||
|
kubesolo-cloudinit dump /path/to/cloud-init.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `cloud-init/examples/` for complete configuration examples:
|
||||||
|
|
||||||
|
- `dhcp.yaml` — DHCP with defaults
|
||||||
|
- `static-ip.yaml` — Static IP configuration
|
||||||
|
- `portainer-edge.yaml` — Portainer Edge Agent integration
|
||||||
|
- `airgapped.yaml` — Air-gapped deployment with pre-loaded images
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The cloud-init binary is built as part of the normal build process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build just the cloud-init binary
|
||||||
|
make build-cloudinit
|
||||||
|
|
||||||
|
# Run cloud-init unit tests
|
||||||
|
make test-cloudinit
|
||||||
|
|
||||||
|
# Full build (includes cloud-init)
|
||||||
|
make iso
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is compiled as a static Linux/amd64 binary (`CGO_ENABLED=0`) and is approximately 2.7 MB.
|
||||||
35
init/lib/45-cloud-init.sh
Normal file
35
init/lib/45-cloud-init.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 45-cloud-init.sh — Apply cloud-init configuration
|
||||||
|
#
|
||||||
|
# Runs the kubesolo-cloudinit binary to parse cloud-init.yaml and apply:
|
||||||
|
# - hostname (/etc/hostname, /etc/hosts)
|
||||||
|
# - network (static IP or DHCP)
|
||||||
|
# - KubeSolo settings (/etc/kubesolo/extra-flags, config.yaml)
|
||||||
|
# - persistent configs saved to data partition
|
||||||
|
#
|
||||||
|
# If no cloud-init file is found, this stage is a no-op and later stages
|
||||||
|
# (50-network, 60-hostname) handle defaults.
|
||||||
|
|
||||||
|
CLOUDINIT_BIN="/usr/lib/kubesolo-os/kubesolo-cloudinit"
|
||||||
|
CLOUDINIT_FILE="${KUBESOLO_CLOUDINIT:-$DATA_MOUNT/etc-kubesolo/cloud-init.yaml}"
|
||||||
|
|
||||||
|
if [ ! -x "$CLOUDINIT_BIN" ]; then
|
||||||
|
log_warn "cloud-init binary not found at $CLOUDINIT_BIN — skipping"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CLOUDINIT_FILE" ]; then
|
||||||
|
log "No cloud-init config found at $CLOUDINIT_FILE — skipping"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Applying cloud-init from: $CLOUDINIT_FILE"
|
||||||
|
|
||||||
|
if "$CLOUDINIT_BIN" apply "$CLOUDINIT_FILE"; then
|
||||||
|
log_ok "cloud-init applied successfully"
|
||||||
|
# Signal to later stages that cloud-init handled network/hostname
|
||||||
|
CLOUDINIT_APPLIED=1
|
||||||
|
export CLOUDINIT_APPLIED
|
||||||
|
else
|
||||||
|
log_err "cloud-init apply failed — later stages will use defaults"
|
||||||
|
fi
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# 50-network.sh — Configure networking
|
# 50-network.sh — Configure networking
|
||||||
# Priority: persistent config > cloud-init > DHCP fallback
|
# Priority: cloud-init (stage 45) > saved config > DHCP fallback
|
||||||
|
|
||||||
|
# If cloud-init already configured networking, skip this stage
|
||||||
|
if [ "$CLOUDINIT_APPLIED" = "1" ]; then
|
||||||
|
log "Network already configured by cloud-init — skipping"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Check for saved network config (from previous boot or cloud-init)
|
# Check for saved network config (from previous boot or cloud-init)
|
||||||
if [ -f "$DATA_MOUNT/network/interfaces.sh" ]; then
|
if [ -f "$DATA_MOUNT/network/interfaces.sh" ]; then
|
||||||
@@ -9,15 +15,6 @@ if [ -f "$DATA_MOUNT/network/interfaces.sh" ]; then
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for cloud-init network config
|
|
||||||
CLOUDINIT_FILE="${KUBESOLO_CLOUDINIT:-$DATA_MOUNT/etc-kubesolo/cloud-init.yaml}"
|
|
||||||
if [ -f "$CLOUDINIT_FILE" ]; then
|
|
||||||
log "Cloud-init found: $CLOUDINIT_FILE"
|
|
||||||
# Phase 1: simple parsing — extract network stanza
|
|
||||||
# TODO: Replace with proper cloud-init parser (Go binary) in Phase 2
|
|
||||||
log_warn "Cloud-init network parsing not yet implemented — falling back to DHCP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback: DHCP on first non-loopback interface
|
# Fallback: DHCP on first non-loopback interface
|
||||||
log "Configuring network via DHCP"
|
log "Configuring network via DHCP"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# 60-hostname.sh — Set system hostname
|
# 60-hostname.sh — Set system hostname
|
||||||
|
# If cloud-init (stage 45) already set the hostname, skip this stage.
|
||||||
|
|
||||||
|
# Cloud-init writes /etc/hostname and saves to data partition
|
||||||
|
if [ "$CLOUDINIT_APPLIED" = "1" ] && [ -f /etc/hostname ]; then
|
||||||
|
HOSTNAME="$(cat /etc/hostname)"
|
||||||
|
if [ -n "$HOSTNAME" ]; then
|
||||||
|
log "Hostname already set by cloud-init: $HOSTNAME"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f "$DATA_MOUNT/etc-kubesolo/hostname" ]; then
|
if [ -f "$DATA_MOUNT/etc-kubesolo/hostname" ]; then
|
||||||
HOSTNAME="$(cat "$DATA_MOUNT/etc-kubesolo/hostname")"
|
HOSTNAME="$(cat "$DATA_MOUNT/etc-kubesolo/hostname")"
|
||||||
|
|||||||
Reference in New Issue
Block a user