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>
169 lines
6.0 KiB
Go
169 lines
6.0 KiB
Go
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"
|
|
)
|
|
|
|
// Apply downloads a new OS image and writes it to the passive partition.
|
|
// It does NOT activate the new partition — use 'activate' for that.
|
|
//
|
|
// State transitions: Idle/Success/Failed → Checking → Downloading → Staged.
|
|
// On any error the state moves to Failed with LastError set.
|
|
func Apply(args []string) error {
|
|
opts := parseOpts(args)
|
|
|
|
if opts.ServerURL == "" {
|
|
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)
|
|
if err != nil {
|
|
// Don't block the operation on a corrupt state file. Log + recover.
|
|
slog.Warn("state file unreadable, starting fresh", "error", err)
|
|
st = state.New()
|
|
}
|
|
|
|
env := opts.NewBootEnv()
|
|
|
|
// Record the current running version as the "from" reference. The active
|
|
// slot's version file is the most reliable source.
|
|
activeSlot, slotErr := env.ActiveSlot()
|
|
if slotErr == nil {
|
|
if partInfo, perr := partition.GetSlotPartition(activeSlot); perr == nil {
|
|
mp := "/tmp/kubesolo-active-" + activeSlot
|
|
if merr := partition.MountReadOnly(partInfo.Device, mp); merr == nil {
|
|
if v, rerr := partition.ReadVersion(mp); rerr == nil {
|
|
st.SetFromVersion(v)
|
|
}
|
|
partition.Unmount(mp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine passive slot
|
|
passiveSlot, err := env.PassiveSlot()
|
|
if err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("reading passive slot: %w", err))
|
|
return fmt.Errorf("reading passive slot: %w", err)
|
|
}
|
|
|
|
slog.Info("applying update", "target_slot", passiveSlot)
|
|
|
|
// Check for update
|
|
stageDir := "/tmp/kubesolo-update-stage"
|
|
client := image.NewClient(opts.ServerURL, stageDir)
|
|
defer client.Cleanup()
|
|
|
|
// Enable signature verification if public key is configured
|
|
if opts.PubKeyPath != "" {
|
|
client.SetPublicKeyPath(opts.PubKeyPath)
|
|
slog.Info("signature verification enabled", "pubkey", opts.PubKeyPath)
|
|
}
|
|
|
|
if err := st.Transition(opts.StatePath, state.PhaseChecking, "", ""); err != nil {
|
|
slog.Warn("state transition failed", "phase", state.PhaseChecking, "error", err)
|
|
}
|
|
|
|
meta, err := client.CheckForUpdate()
|
|
if err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("checking for update: %w", err))
|
|
return fmt.Errorf("checking for update: %w", err)
|
|
}
|
|
|
|
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).
|
|
if err := st.Transition(opts.StatePath, state.PhaseDownloading, meta.Version, ""); err != nil {
|
|
slog.Warn("state transition failed", "phase", state.PhaseDownloading, "error", err)
|
|
}
|
|
|
|
// Download and verify
|
|
staged, err := client.Download(meta)
|
|
if err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("downloading update: %w", err))
|
|
return fmt.Errorf("downloading update: %w", err)
|
|
}
|
|
|
|
// Mount passive partition
|
|
partInfo, err := partition.GetSlotPartition(passiveSlot)
|
|
if err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("finding passive partition: %w", err))
|
|
return fmt.Errorf("finding passive partition: %w", err)
|
|
}
|
|
|
|
mountPoint := "/tmp/kubesolo-passive-" + passiveSlot
|
|
if err := partition.MountReadWrite(partInfo.Device, mountPoint); err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("mounting passive partition: %w", err))
|
|
return fmt.Errorf("mounting passive partition: %w", err)
|
|
}
|
|
defer partition.Unmount(mountPoint)
|
|
|
|
// Write image to passive partition
|
|
if err := partition.WriteSystemImage(mountPoint, staged.VmlinuzPath, staged.InitramfsPath, staged.Version); err != nil {
|
|
_ = st.RecordError(opts.StatePath, fmt.Errorf("writing system image: %w", err))
|
|
return fmt.Errorf("writing system image: %w", err)
|
|
}
|
|
|
|
if err := st.Transition(opts.StatePath, state.PhaseStaged, staged.Version, ""); err != nil {
|
|
slog.Warn("state transition failed", "phase", state.PhaseStaged, "error", err)
|
|
}
|
|
|
|
fmt.Printf("Update v%s written to slot %s (%s)\n", staged.Version, passiveSlot, partInfo.Device)
|
|
fmt.Println("Run 'kubesolo-update activate' to boot into the new version")
|
|
|
|
return nil
|
|
}
|