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>
96 lines
2.8 KiB
Go
96 lines
2.8 KiB
Go
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)
|
||
}
|