Files
kubesolo-os/cloud-init/cmd/main.go
Adolfo Delorenzo dfed6ddba8
Some checks failed
ARM64 Build / Build generic ARM64 disk image (push) Failing after 3s
CI / Go Tests (push) Successful in 1m23s
CI / Shellcheck (push) Successful in 46s
CI / Build Go Binaries (amd64, linux, linux-amd64) (push) Successful in 1m32s
CI / Build Go Binaries (arm64, linux, linux-arm64) (push) Successful in 1m15s
feat(update): channels, maintenance windows, min-version gate
Phase 6 of v0.3. The update agent now refuses to apply artifacts whose
channel doesn't match local policy, whose architecture differs from the
running host, or whose min_compatible_version is above the current
version. It also refuses to apply outside a configured maintenance window
unless --force is given.

New package update/pkg/config:
- config.Load parses /etc/kubesolo/update.conf (key=value, # comments,
  unknown keys ignored). Missing file is fine — fresh systems before
  cloud-init has run.
- ParseWindow handles "HH:MM-HH:MM" plus the wrapping midnight case
  (e.g. "23:00-01:00"). Empty input -> AlwaysOpen (no constraint).
  Degenerate zero-length windows never match.
- CompareVersions does a simple 3-component semver compare with the 'v'
  prefix optional and pre-release suffix ignored.
- 14 unit tests total.

update/pkg/image/image.UpdateMetadata gains three optional fields:
- channel ("stable", "beta", ...)
- min_compatible_version (refuse upgrade if current < this)
- architecture ("amd64", "arm64", ...)

update/cmd/opts.go reads update.conf and merges it into opts; explicit
--server / --channel / --pubkey / --maintenance-window CLI flags override
the file. New --force, --conf, --channel, --maintenance-window flags.
Precedence: CLI > config file > package defaults.

update/cmd/apply.go gains four gates in order:
1. Maintenance window — checked locally before any HTTP work; skipped
   with --force.
2. Channel — refused if metadata.channel doesn't match opts.Channel.
3. Architecture — refused if metadata.architecture != runtime.GOARCH.
4. Min compatible version — refused if FromVersion < min_compatible.
All gate failures transition state to Failed with a clear LastError.

cloud-init gains a top-level updates: block (Server, Channel,
MaintenanceWindow, PubKey). cloud-init.ApplyUpdates writes
/etc/kubesolo/update.conf from those fields on first boot. Empty block
leaves any existing file alone (so hand-edited update.conf survives a
reboot without cloud-init re-applying). 4 new tests cover empty / all /
partial / parent-dir-creation cases. full-config.yaml example updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:21:46 -06:00

143 lines
3.4 KiB
Go

// 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. Apply Portainer Edge Agent manifest (if enabled)
if err := cloudinit.ApplyPortainer(cfg, "/var/lib/kubesolo/server/manifests"); err != nil {
return fmt.Errorf("portainer edge agent: %w", err)
}
// 5. Write /etc/kubesolo/update.conf from updates: block (if any).
if err := cloudinit.ApplyUpdates(cfg, ""); err != nil {
return fmt.Errorf("updates: %w", err)
}
// 5. 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)
}