Files
kubesolo-os/update/pkg/config/window.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

96 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}