feat(update): channels, maintenance windows, min-version gate
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

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>
This commit is contained in:
2026-05-14 18:21:46 -06:00
parent bce565e2f7
commit dfed6ddba8
14 changed files with 839 additions and 16 deletions

View File

@@ -97,6 +97,11 @@ func cmdApply(configPath string) error {
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)

View File

@@ -12,12 +12,30 @@ 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"`
Hostname string `yaml:"hostname"`
Network NetworkConfig `yaml:"network"`
KubeSolo KubeSoloConfig `yaml:"kubesolo"`
NTP NTPConfig `yaml:"ntp"`
Airgap AirgapConfig `yaml:"airgap"`
Portainer PortainerConfig `yaml:"portainer"`
Updates UpdatesConfig `yaml:"updates"`
}
// UpdatesConfig configures the kubesolo-update agent. Written to
// /etc/kubesolo/update.conf on first boot. See update/pkg/config.
type UpdatesConfig struct {
// Server is the update server URL (HTTP or OCI registry).
Server string `yaml:"server"`
// Channel selects which channel to track ("stable", "beta", "edge").
// Empty = "stable".
Channel string `yaml:"channel"`
// MaintenanceWindow restricts apply to the given local time range,
// e.g. "03:00-05:00". Wrapping windows like "23:00-01:00" supported.
// Empty = no restriction.
MaintenanceWindow string `yaml:"maintenance_window"`
// PubKey is the path to the Ed25519 public key file used to verify
// signed update artifacts. Empty = signature verification disabled.
PubKey string `yaml:"pubkey"`
}
// NetworkConfig defines network settings.

View File

@@ -50,3 +50,25 @@ kubesolo:
# Arbitrary extra flags passed directly to the KubeSolo binary
# extra-flags: "--disable traefik --disable servicelb"
# Update agent settings (written to /etc/kubesolo/update.conf on first boot).
# Omit any subfield to leave the corresponding default in place.
updates:
# Update server URL — HTTPS for the JSON+blob protocol, or an OCI registry
# reference (e.g. ghcr.io/portainer/kubesolo-os) when OCI distribution
# lands in v0.3.
server: "https://updates.kubesolo.example.com"
# Channel to track. "stable" is the default; "beta"/"edge" expose
# pre-release artifacts. The agent refuses to apply metadata whose
# channel doesn't match.
channel: "stable"
# Maintenance window (local time, HH:MM-HH:MM, wrapping midnight OK).
# `apply` refuses to run outside this window unless --force is passed.
# Leave empty (or omit) to allow updates at any time.
maintenance_window: "03:00-05:00"
# Path to Ed25519 public key for signature verification. Omit to disable
# signature verification (NOT recommended for production fleets).
# pubkey: "/etc/kubesolo/update-pubkey.hex"

57
cloud-init/updates.go Normal file
View File

@@ -0,0 +1,57 @@
package cloudinit
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
// DefaultUpdateConfPath is where the update agent expects to find its config.
// Kept in sync with update/pkg/config.DefaultPath.
const DefaultUpdateConfPath = "/etc/kubesolo/update.conf"
// ApplyUpdates writes /etc/kubesolo/update.conf from the cloud-init
// updates: block. Called once per boot; idempotent (overwrites any existing
// file with the cloud-init values).
//
// If the updates: block is empty (all fields blank), the file is not
// written — preserves any hand-edited update.conf on systems that aren't
// managed via cloud-init.
func ApplyUpdates(cfg *Config, confPath string) error {
if confPath == "" {
confPath = DefaultUpdateConfPath
}
u := cfg.Updates
if u.Server == "" && u.Channel == "" && u.MaintenanceWindow == "" && u.PubKey == "" {
// Nothing to write — leave any existing file alone.
return nil
}
if err := os.MkdirAll(filepath.Dir(confPath), 0o755); err != nil {
return fmt.Errorf("creating dir for %s: %w", confPath, err)
}
var sb strings.Builder
sb.WriteString("# Generated by KubeSolo OS cloud-init — edit this file or the\n")
sb.WriteString("# cloud-init source YAML; subsequent first-boots will regenerate it.\n")
if u.Server != "" {
fmt.Fprintf(&sb, "server = %s\n", u.Server)
}
if u.Channel != "" {
fmt.Fprintf(&sb, "channel = %s\n", u.Channel)
}
if u.MaintenanceWindow != "" {
fmt.Fprintf(&sb, "maintenance_window = %s\n", u.MaintenanceWindow)
}
if u.PubKey != "" {
fmt.Fprintf(&sb, "pubkey = %s\n", u.PubKey)
}
if err := os.WriteFile(confPath, []byte(sb.String()), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", confPath, err)
}
slog.Info("wrote update.conf", "path", confPath)
return nil
}

View File

@@ -0,0 +1,81 @@
package cloudinit
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestApplyUpdatesEmptyConfigSkipsWrite(t *testing.T) {
confPath := filepath.Join(t.TempDir(), "update.conf")
cfg := &Config{} // Updates block default-zero
if err := ApplyUpdates(cfg, confPath); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(confPath); !os.IsNotExist(err) {
t.Errorf("expected no file when cloud-init Updates is empty, got %v", err)
}
}
func TestApplyUpdatesAllFields(t *testing.T) {
confPath := filepath.Join(t.TempDir(), "update.conf")
cfg := &Config{Updates: UpdatesConfig{
Server: "https://updates.example.com",
Channel: "stable",
MaintenanceWindow: "03:00-05:00",
PubKey: "/etc/kubesolo/pub.hex",
}}
if err := ApplyUpdates(cfg, confPath); err != nil {
t.Fatalf("apply: %v", err)
}
data, err := os.ReadFile(confPath)
if err != nil {
t.Fatalf("read: %v", err)
}
out := string(data)
wants := []string{
"server = https://updates.example.com",
"channel = stable",
"maintenance_window = 03:00-05:00",
"pubkey = /etc/kubesolo/pub.hex",
}
for _, w := range wants {
if !strings.Contains(out, w) {
t.Errorf("update.conf missing %q in output:\n%s", w, out)
}
}
}
func TestApplyUpdatesPartialFields(t *testing.T) {
// Only server set — others should be omitted from the file, not written
// as blank values.
confPath := filepath.Join(t.TempDir(), "update.conf")
cfg := &Config{Updates: UpdatesConfig{Server: "https://x.example.com"}}
if err := ApplyUpdates(cfg, confPath); err != nil {
t.Fatalf("apply: %v", err)
}
data, _ := os.ReadFile(confPath)
out := string(data)
if !strings.Contains(out, "server = https://x.example.com") {
t.Errorf("missing server line:\n%s", out)
}
for _, unwanted := range []string{"channel = ", "maintenance_window = ", "pubkey = "} {
if strings.Contains(out, unwanted) {
t.Errorf("unexpected empty line %q present in:\n%s", unwanted, out)
}
}
}
func TestApplyUpdatesCreatesParentDir(t *testing.T) {
// /etc/kubesolo may not exist on first boot before cloud-init runs.
confPath := filepath.Join(t.TempDir(), "nested", "kubesolo", "update.conf")
cfg := &Config{Updates: UpdatesConfig{Server: "https://x"}}
if err := ApplyUpdates(cfg, confPath); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(confPath); err != nil {
t.Errorf("file not created: %v", err)
}
}