From dfed6ddba88c463e7302c4ff36997dcfad089907 Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Thu, 14 May 2026 18:21:46 -0600 Subject: [PATCH] feat(update): channels, maintenance windows, min-version gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cloud-init/cmd/main.go | 5 ++ cloud-init/config.go | 28 +++++-- cloud-init/examples/full-config.yaml | 22 +++++ cloud-init/updates.go | 57 +++++++++++++ cloud-init/updates_test.go | 81 ++++++++++++++++++ update/cmd/apply.go | 51 +++++++++++- update/cmd/opts.go | 72 ++++++++++++++-- update/pkg/config/config.go | 83 ++++++++++++++++++ update/pkg/config/config_test.go | 117 ++++++++++++++++++++++++++ update/pkg/config/version.go | 60 ++++++++++++++ update/pkg/config/version_test.go | 46 ++++++++++ update/pkg/config/window.go | 95 +++++++++++++++++++++ update/pkg/config/window_test.go | 120 +++++++++++++++++++++++++++ update/pkg/image/image.go | 18 ++++ 14 files changed, 839 insertions(+), 16 deletions(-) create mode 100644 cloud-init/updates.go create mode 100644 cloud-init/updates_test.go create mode 100644 update/pkg/config/config.go create mode 100644 update/pkg/config/config_test.go create mode 100644 update/pkg/config/version.go create mode 100644 update/pkg/config/version_test.go create mode 100644 update/pkg/config/window.go create mode 100644 update/pkg/config/window_test.go diff --git a/cloud-init/cmd/main.go b/cloud-init/cmd/main.go index 5191926..40a9c98 100644 --- a/cloud-init/cmd/main.go +++ b/cloud-init/cmd/main.go @@ -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) diff --git a/cloud-init/config.go b/cloud-init/config.go index dfbc065..69fe8be 100644 --- a/cloud-init/config.go +++ b/cloud-init/config.go @@ -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. diff --git a/cloud-init/examples/full-config.yaml b/cloud-init/examples/full-config.yaml index 9e6ce67..17b9e54 100644 --- a/cloud-init/examples/full-config.yaml +++ b/cloud-init/examples/full-config.yaml @@ -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" diff --git a/cloud-init/updates.go b/cloud-init/updates.go new file mode 100644 index 0000000..a82be58 --- /dev/null +++ b/cloud-init/updates.go @@ -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 +} diff --git a/cloud-init/updates_test.go b/cloud-init/updates_test.go new file mode 100644 index 0000000..0757d09 --- /dev/null +++ b/cloud-init/updates_test.go @@ -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) + } +} diff --git a/update/cmd/apply.go b/update/cmd/apply.go index 9f19bc4..f7c4e1d 100644 --- a/update/cmd/apply.go +++ b/update/cmd/apply.go @@ -3,7 +3,10 @@ package cmd import ( "fmt" "log/slog" + "runtime" + "time" + "github.com/portainer/kubesolo-os/update/pkg/config" "github.com/portainer/kubesolo-os/update/pkg/image" "github.com/portainer/kubesolo-os/update/pkg/partition" "github.com/portainer/kubesolo-os/update/pkg/state" @@ -18,7 +21,18 @@ func Apply(args []string) error { opts := parseOpts(args) if opts.ServerURL == "" { - return fmt.Errorf("--server is required") + return fmt.Errorf("--server is required (or set in /etc/kubesolo/update.conf)") + } + + // Maintenance window gate — earliest cheap check, before any HTTP work. + // Skipped with --force. + window, werr := config.ParseWindow(opts.MaintenanceWindow) + if werr != nil { + return fmt.Errorf("parse maintenance_window: %w", werr) + } + if !opts.Force && !window.Contains(time.Now()) { + return fmt.Errorf("outside maintenance window (%s); pass --force to override", + window.String()) } st, err := state.Load(opts.StatePath) @@ -75,7 +89,40 @@ func Apply(args []string) error { return fmt.Errorf("checking for update: %w", err) } - slog.Info("update available", "version", meta.Version) + slog.Info("update available", "version", meta.Version, "channel", meta.Channel, "arch", meta.Architecture) + + // Channel gate — refuse mismatched channels. + if meta.Channel != "" && meta.Channel != opts.Channel { + err := fmt.Errorf("metadata channel %q does not match local channel %q", + meta.Channel, opts.Channel) + _ = st.RecordError(opts.StatePath, err) + return err + } + + // Architecture gate — refuse cross-arch artifacts. runtime.GOARCH is our + // canonical identifier ("amd64", "arm64", "arm"). + if meta.Architecture != "" && meta.Architecture != runtime.GOARCH { + err := fmt.Errorf("metadata architecture %q does not match runtime %q", + meta.Architecture, runtime.GOARCH) + _ = st.RecordError(opts.StatePath, err) + return err + } + + // Min-compatible-version gate — refuse if a stepping-stone is required. + // We need the active slot's version (already populated into st.FromVersion + // earlier in this command). + if meta.MinCompatibleVersion != "" && st.FromVersion != "" { + cmp, cerr := config.CompareVersions(st.FromVersion, meta.MinCompatibleVersion) + if cerr != nil { + slog.Warn("min-version comparison failed", "error", cerr, + "from", st.FromVersion, "min", meta.MinCompatibleVersion) + } else if cmp < 0 { + err := fmt.Errorf("current version %s is below min_compatible_version %s; install %s first", + st.FromVersion, meta.MinCompatibleVersion, meta.MinCompatibleVersion) + _ = st.RecordError(opts.StatePath, err) + return err + } + } // Now we know the target version — record it (resets attempt count if it // differs from the previous attempt's ToVersion). diff --git a/update/cmd/opts.go b/update/cmd/opts.go index 3ac64a3..9472f2b 100644 --- a/update/cmd/opts.go +++ b/update/cmd/opts.go @@ -1,20 +1,27 @@ package cmd import ( + "log/slog" + "github.com/portainer/kubesolo-os/update/pkg/bootenv" + "github.com/portainer/kubesolo-os/update/pkg/config" "github.com/portainer/kubesolo-os/update/pkg/state" ) // opts holds shared command-line options for all subcommands. type opts struct { - ServerURL string - GrubenvPath string - TimeoutSecs int - PubKeyPath string - BootEnvType string // "grub" or "rpi" - BootEnvPath string // path for RPi boot control dir - StatePath string // location of state.json (default: state.DefaultPath) - JSON bool // status: emit JSON instead of human-readable + ServerURL string + GrubenvPath string + TimeoutSecs int + PubKeyPath string + BootEnvType string // "grub" or "rpi" + BootEnvPath string // path for RPi boot control dir + StatePath string // location of state.json (default: state.DefaultPath) + ConfPath string // location of update.conf (default: config.DefaultPath) + Channel string // update channel ("stable" by default) + MaintenanceWindow string // "HH:MM-HH:MM" or empty for always-allow + Force bool // bypass maintenance window + JSON bool // status: emit JSON instead of human-readable } // NewBootEnv creates a BootEnv from the parsed options. @@ -28,22 +35,69 @@ func (o opts) NewBootEnv() bootenv.BootEnv { } // parseOpts extracts command-line flags from args. -// Simple parser — no external dependencies. +// +// Precedence: explicit CLI flags > /etc/kubesolo/update.conf > package +// defaults. The config file is loaded first so any CLI flag overrides it. +// +// Unknown flags are ignored (forward-compat). func parseOpts(args []string) opts { o := opts{ GrubenvPath: "/boot/grub/grubenv", TimeoutSecs: 120, BootEnvType: "grub", StatePath: state.DefaultPath, + ConfPath: config.DefaultPath, + Channel: "stable", } + // First pass: pick up --conf so it can point at a different file before + // we load. (Tests pass --conf /update.conf.) + for i := 0; i < len(args); i++ { + if args[i] == "--conf" && i+1 < len(args) { + o.ConfPath = args[i+1] + } + } + + // Load config file. Missing file is fine (fresh system, no cloud-init yet). + if cfg, err := config.Load(o.ConfPath); err == nil && cfg != nil { + if cfg.Server != "" { + o.ServerURL = cfg.Server + } + if cfg.Channel != "" { + o.Channel = cfg.Channel + } + if cfg.MaintenanceWindow != "" { + o.MaintenanceWindow = cfg.MaintenanceWindow + } + if cfg.PubKey != "" { + o.PubKeyPath = cfg.PubKey + } + } else if err != nil { + slog.Warn("could not load update.conf", "path", o.ConfPath, "error", err) + } + + // Second pass: CLI overrides config file values. for i := 0; i < len(args); i++ { switch args[i] { + case "--conf": + i++ // already handled above case "--state": if i+1 < len(args) { o.StatePath = args[i+1] i++ } + case "--channel": + if i+1 < len(args) { + o.Channel = args[i+1] + i++ + } + case "--maintenance-window": + if i+1 < len(args) { + o.MaintenanceWindow = args[i+1] + i++ + } + case "--force": + o.Force = true case "--json": o.JSON = true case "--server": diff --git a/update/pkg/config/config.go b/update/pkg/config/config.go new file mode 100644 index 0000000..bcd2978 --- /dev/null +++ b/update/pkg/config/config.go @@ -0,0 +1,83 @@ +// Package config parses /etc/kubesolo/update.conf — the persistent +// configuration for the update agent. Each line is "key = value"; blank +// lines and "#"-prefixed comments are ignored. Unknown keys are tolerated +// (forward compatibility). +// +// Example: +// +// # Where to look for updates +// server = https://updates.kubesolo.example.com +// channel = stable +// +// # Only apply between 03:00 and 05:00 local time +// maintenance_window = 03:00-05:00 +// +// pubkey = /etc/kubesolo/update-pubkey.hex +// +// The file is populated on first boot by cloud-init (see the cloud-init +// updates: block) and can be hand-edited afterwards. +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// DefaultPath is where update.conf lives on a live system. +const DefaultPath = "/etc/kubesolo/update.conf" + +// Config holds the parsed update.conf values. Empty fields mean "not set" — +// the caller's defaults apply. +type Config struct { + Server string + Channel string + MaintenanceWindow string + PubKey string +} + +// Load reads and parses update.conf. A missing file returns an empty Config +// (not an error) — fresh systems before cloud-init has run. +func Load(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{}, nil + } + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + c := &Config{} + scanner := bufio.NewScanner(f) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + return nil, fmt.Errorf("%s:%d: missing '=' in line: %q", path, lineNo, line) + } + key := strings.TrimSpace(line[:eq]) + value := strings.TrimSpace(line[eq+1:]) + switch key { + case "server": + c.Server = value + case "channel": + c.Channel = value + case "maintenance_window": + c.MaintenanceWindow = value + case "pubkey": + c.PubKey = value + } + // Unknown keys are silently ignored for forward compatibility. + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return c, nil +} diff --git a/update/pkg/config/config_test.go b/update/pkg/config/config_test.go new file mode 100644 index 0000000..771ec07 --- /dev/null +++ b/update/pkg/config/config_test.go @@ -0,0 +1,117 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConf(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "update.conf") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + return path +} + +func TestLoadMissingReturnsEmptyConfig(t *testing.T) { + c, err := Load(filepath.Join(t.TempDir(), "does-not-exist.conf")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c == nil { + t.Fatal("Load returned nil config") + } + if c.Server != "" || c.Channel != "" || c.MaintenanceWindow != "" || c.PubKey != "" { + t.Errorf("expected empty config, got %+v", c) + } +} + +func TestLoadAllFields(t *testing.T) { + path := writeConf(t, `# comment line +server = https://updates.example.com +channel = stable +maintenance_window = 03:00-05:00 +pubkey = /etc/kubesolo/pub.hex +`) + c, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.Server != "https://updates.example.com" { + t.Errorf("server: got %q", c.Server) + } + if c.Channel != "stable" { + t.Errorf("channel: got %q", c.Channel) + } + if c.MaintenanceWindow != "03:00-05:00" { + t.Errorf("maintenance_window: got %q", c.MaintenanceWindow) + } + if c.PubKey != "/etc/kubesolo/pub.hex" { + t.Errorf("pubkey: got %q", c.PubKey) + } +} + +func TestLoadIgnoresUnknownKeys(t *testing.T) { + // Unknown keys must not be an error — supports forward-compat config + // fields added by newer agent versions. + path := writeConf(t, `server = https://x +future_field = whatever +channel = beta +`) + c, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.Server != "https://x" { + t.Errorf("server: got %q", c.Server) + } + if c.Channel != "beta" { + t.Errorf("channel: got %q", c.Channel) + } +} + +func TestLoadStripsWhitespace(t *testing.T) { + path := writeConf(t, " server = https://example \n channel=stable\n") + c, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.Server != "https://example" { + t.Errorf("server: got %q (whitespace not stripped?)", c.Server) + } + if c.Channel != "stable" { + t.Errorf("channel: got %q", c.Channel) + } +} + +func TestLoadIgnoresBlankAndCommentLines(t *testing.T) { + path := writeConf(t, ` +# this is a comment + +server = https://example + # indented comment +channel = stable + +`) + c, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.Server != "https://example" { + t.Errorf("server: got %q", c.Server) + } +} + +func TestLoadRejectsMissingEquals(t *testing.T) { + // "noEqualsHere" with no '=' is a syntax error worth surfacing — likely + // indicates a corrupted config file. + path := writeConf(t, `server = https://example +noEqualsHere +`) + _, err := Load(path) + if err == nil { + t.Error("expected error on malformed line, got nil") + } +} diff --git a/update/pkg/config/version.go b/update/pkg/config/version.go new file mode 100644 index 0000000..32d4b50 --- /dev/null +++ b/update/pkg/config/version.go @@ -0,0 +1,60 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +// CompareVersions compares two semver-ish version strings. +// +// Accepts "v1.2.3", "1.2.3", "v1.2.3-rc1" (suffix ignored), with missing +// components defaulting to 0 ("v1" == "1.0.0"). Returns -1 if a < b, 0 if +// equal, +1 if a > b. Returns an error if either argument can't be parsed +// at all. +// +// Used by apply.go to enforce MinCompatibleVersion. Pre-release suffix +// handling is deliberately simple — we ignore it, treating "v1.2.3-rc1" +// equal to "v1.2.3". Edge case: production releases should never carry +// a pre-release suffix, and dev releases are the consumer's responsibility. +func CompareVersions(a, b string) (int, error) { + pa, err := parseVersion(a) + if err != nil { + return 0, fmt.Errorf("parse %q: %w", a, err) + } + pb, err := parseVersion(b) + if err != nil { + return 0, fmt.Errorf("parse %q: %w", b, err) + } + for i := 0; i < 3; i++ { + if pa[i] < pb[i] { + return -1, nil + } + if pa[i] > pb[i] { + return 1, nil + } + } + return 0, nil +} + +func parseVersion(s string) ([3]int, error) { + var out [3]int + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + // Drop pre-release suffix: "1.2.3-rc1" -> "1.2.3" + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.SplitN(s, ".", 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return out, fmt.Errorf("component %q not numeric", p) + } + if n < 0 { + return out, fmt.Errorf("component %d negative", n) + } + out[i] = n + } + return out, nil +} diff --git a/update/pkg/config/version_test.go b/update/pkg/config/version_test.go new file mode 100644 index 0000000..30d67e1 --- /dev/null +++ b/update/pkg/config/version_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +func TestCompareVersions(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"v1.0.0", "v1.0.0", 0}, + {"1.0.0", "v1.0.0", 0}, // 'v' prefix optional + {"v1.0.0", "v1.0.1", -1}, + {"v1.0.1", "v1.0.0", 1}, + {"v1.1.0", "v1.0.99", 1}, + {"v2.0.0", "v1.99.99", 1}, + {"v0.3.0-dev", "v0.3.0", 0}, // pre-release suffix ignored + {"v0.2.5", "v0.3.0", -1}, + {"v0.3.0", "v0.2.999", 1}, + {"v1.2", "v1.2.0", 0}, // missing component defaults to 0 + {"v1", "v1.0.0", 0}, + } + for _, tt := range tests { + got, err := CompareVersions(tt.a, tt.b) + if err != nil { + t.Errorf("CompareVersions(%q, %q): %v", tt.a, tt.b, err) + continue + } + if got != tt.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestCompareVersionsRejectsGarbage(t *testing.T) { + bad := []string{ + "not-a-version", + "v.1.2", + "vabc", + "", + } + for _, s := range bad { + if _, err := CompareVersions(s, "v1.0.0"); err == nil { + t.Errorf("CompareVersions(%q, ...) accepted, want error", s) + } + } +} diff --git a/update/pkg/config/window.go b/update/pkg/config/window.go new file mode 100644 index 0000000..62ab1eb --- /dev/null +++ b/update/pkg/config/window.go @@ -0,0 +1,95 @@ +package config + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Window is a parsed maintenance-window expression. Times are minutes since +// midnight in the local timezone. When End < Start, the window wraps +// midnight (e.g. 23:00-01:00 means 23:00 today through 01:00 tomorrow). +// +// The zero value (Start == End == 0) means "always allowed" — used for +// the empty-string-meaning-no-window case. +type Window struct { + Start int // minutes since midnight, [0, 1440) + End int // minutes since midnight, [0, 1440) + + // alwaysOpen distinguishes "no constraint" from "midnight to midnight" + // (the literal 00:00-00:00 window, which is a degenerate same-instant + // window). Set when ParseWindow is called with an empty string. + alwaysOpen bool +} + +// AlwaysOpen returns true if this window imposes no constraint (the empty +// string was parsed). +func (w Window) AlwaysOpen() bool { return w.alwaysOpen } + +// ParseWindow parses "HH:MM-HH:MM" into a Window. Empty input returns an +// AlwaysOpen window (no constraint). Whitespace around the input is tolerated. +func ParseWindow(s string) (Window, error) { + s = strings.TrimSpace(s) + if s == "" { + return Window{alwaysOpen: true}, nil + } + parts := strings.SplitN(s, "-", 2) + if len(parts) != 2 { + return Window{}, fmt.Errorf("maintenance window %q: expected HH:MM-HH:MM", s) + } + start, err := parseHHMM(strings.TrimSpace(parts[0])) + if err != nil { + return Window{}, fmt.Errorf("maintenance window %q: start: %w", s, err) + } + end, err := parseHHMM(strings.TrimSpace(parts[1])) + if err != nil { + return Window{}, fmt.Errorf("maintenance window %q: end: %w", s, err) + } + return Window{Start: start, End: end}, nil +} + +func parseHHMM(s string) (int, error) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return 0, fmt.Errorf("%q: expected HH:MM", s) + } + h, err := strconv.Atoi(parts[0]) + if err != nil || h < 0 || h > 23 { + return 0, fmt.Errorf("%q: invalid hour", s) + } + m, err := strconv.Atoi(parts[1]) + if err != nil || m < 0 || m > 59 { + return 0, fmt.Errorf("%q: invalid minute", s) + } + return h*60 + m, nil +} + +// Contains reports whether the given local time falls inside this window. +// AlwaysOpen windows return true for any time. +func (w Window) Contains(t time.Time) bool { + if w.alwaysOpen { + return true + } + now := t.Hour()*60 + t.Minute() + if w.Start == w.End { + // Degenerate: zero-length window. Never matches. + return false + } + if w.Start < w.End { + // Same-day window: [Start, End) + return now >= w.Start && now < w.End + } + // Wrapping window: [Start, 1440) ∪ [0, End) + return now >= w.Start || now < w.End +} + +// String renders the window in HH:MM-HH:MM form for display. AlwaysOpen +// renders as "always". +func (w Window) String() string { + if w.alwaysOpen { + return "always" + } + return fmt.Sprintf("%02d:%02d-%02d:%02d", + w.Start/60, w.Start%60, w.End/60, w.End%60) +} diff --git a/update/pkg/config/window_test.go b/update/pkg/config/window_test.go new file mode 100644 index 0000000..3d84866 --- /dev/null +++ b/update/pkg/config/window_test.go @@ -0,0 +1,120 @@ +package config + +import ( + "testing" + "time" +) + +func at(hour, min int) time.Time { + return time.Date(2026, 1, 1, hour, min, 0, 0, time.UTC) +} + +func TestParseWindowEmpty(t *testing.T) { + w, err := ParseWindow("") + if err != nil { + t.Fatalf("empty window: %v", err) + } + if !w.AlwaysOpen() { + t.Error("empty input should produce AlwaysOpen window") + } + if !w.Contains(at(3, 0)) { + t.Error("AlwaysOpen window should contain any time") + } + if !w.Contains(at(23, 59)) { + t.Error("AlwaysOpen window should contain end-of-day") + } +} + +func TestParseWindowSameDay(t *testing.T) { + w, err := ParseWindow("03:00-05:00") + if err != nil { + t.Fatalf("parse: %v", err) + } + tests := []struct { + hour, min int + want bool + }{ + {2, 59, false}, // just before + {3, 0, true}, // start (inclusive) + {4, 30, true}, // middle + {4, 59, true}, // just before end + {5, 0, false}, // end (exclusive) + {15, 0, false}, // far outside + } + for _, tt := range tests { + got := w.Contains(at(tt.hour, tt.min)) + if got != tt.want { + t.Errorf("Contains(%02d:%02d) = %v, want %v", tt.hour, tt.min, got, tt.want) + } + } +} + +func TestParseWindowWrappingMidnight(t *testing.T) { + w, err := ParseWindow("23:00-01:00") + if err != nil { + t.Fatalf("parse: %v", err) + } + tests := []struct { + hour, min int + want bool + }{ + {22, 59, false}, // just before + {23, 0, true}, // start (inclusive) + {23, 30, true}, // night-before + {0, 0, true}, // midnight + {0, 30, true}, // early morning + {0, 59, true}, // just before end + {1, 0, false}, // end (exclusive) + {12, 0, false}, // far outside (noon) + } + for _, tt := range tests { + got := w.Contains(at(tt.hour, tt.min)) + if got != tt.want { + t.Errorf("Contains(%02d:%02d) wrapping = %v, want %v", tt.hour, tt.min, got, tt.want) + } + } +} + +func TestParseWindowDegenerateZeroLength(t *testing.T) { + // 05:00-05:00 is a zero-length window — should never match. Different + // from "always" (empty string). + w, err := ParseWindow("05:00-05:00") + if err != nil { + t.Fatalf("parse: %v", err) + } + if w.AlwaysOpen() { + t.Error("05:00-05:00 must not be AlwaysOpen") + } + if w.Contains(at(5, 0)) { + t.Error("zero-length window must not contain its own boundary") + } +} + +func TestParseWindowRejectsBadInput(t *testing.T) { + bad := []string{ + "notatime", + "03:00", // no end + "03:00-", // empty end + "03:00-05", // missing minutes + "24:00-05:00", // hour out of range + "03:60-05:00", // minute out of range + "abc:00-05:00", // non-numeric + } + for _, s := range bad { + _, err := ParseWindow(s) + if err == nil { + t.Errorf("ParseWindow(%q) accepted, want error", s) + } + } +} + +func TestWindowString(t *testing.T) { + w, _ := ParseWindow("03:05-05:45") + if w.String() != "03:05-05:45" { + t.Errorf("String = %q, want 03:05-05:45", w.String()) + } + always, _ := ParseWindow("") + if always.String() != "always" { + t.Errorf("AlwaysOpen.String = %q, want 'always'", always.String()) + } +} diff --git a/update/pkg/image/image.go b/update/pkg/image/image.go index 7428b67..ac8b0ed 100644 --- a/update/pkg/image/image.go +++ b/update/pkg/image/image.go @@ -35,6 +35,24 @@ type UpdateMetadata struct { MetadataSigURL string `json:"metadata_sig_url,omitempty"` ReleaseNotes string `json:"release_notes,omitempty"` ReleaseDate string `json:"release_date,omitempty"` + + // Channel labels this artifact ("stable", "beta", "edge", ...). The agent + // refuses metadata whose channel doesn't match the locally-configured + // one. Empty in metadata means "no channel constraint, accept anything". + Channel string `json:"channel,omitempty"` + + // MinCompatibleVersion is the lowest version that can upgrade to this + // one. The agent refuses to apply if the currently-running version is + // below this. Used for stepping-stone migrations (e.g. 0.2.x -> 0.3.x + // requires 0.2.5+ to land the state-file format first). Empty means + // "any source version OK". + MinCompatibleVersion string `json:"min_compatible_version,omitempty"` + + // Architecture restricts this artifact to a specific GOARCH ("amd64", + // "arm64"). Empty means the artifact is arch-agnostic — which is rare + // since the kernel + initramfs are arch-specific; this should normally + // be populated by the build pipeline. + Architecture string `json:"architecture,omitempty"` } // StagedImage represents downloaded and verified update files.