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

275 lines
8.1 KiB
Go

// Package image handles downloading, verifying, and staging OS update images.
//
// Update images are distributed as pairs of files:
// - vmlinuz (kernel)
// - kubesolo-os.gz (initramfs)
//
// These are fetched from an HTTP(S) server that provides a metadata file
// (latest.json) describing available updates.
package image
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/portainer/kubesolo-os/update/pkg/signing"
)
// UpdateMetadata describes an available update from the update server.
type UpdateMetadata struct {
Version string `json:"version"`
VmlinuzURL string `json:"vmlinuz_url"`
VmlinuzSHA256 string `json:"vmlinuz_sha256"`
VmlinuzSigURL string `json:"vmlinuz_sig_url,omitempty"`
InitramfsURL string `json:"initramfs_url"`
InitramfsSHA256 string `json:"initramfs_sha256"`
InitramfsSigURL string `json:"initramfs_sig_url,omitempty"`
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.
type StagedImage struct {
VmlinuzPath string
InitramfsPath string
Version string
}
// Client handles communication with the update server.
type Client struct {
serverURL string
httpClient *http.Client
stageDir string
pubKeyPath string // path to Ed25519 public key for signature verification
}
// NewClient creates a new update image client.
func NewClient(serverURL, stageDir string) *Client {
return &Client{
serverURL: serverURL,
httpClient: &http.Client{
Timeout: 5 * time.Minute,
},
stageDir: stageDir,
}
}
// SetPublicKeyPath sets the path to the Ed25519 public key used
// for verifying update signatures. If set, downloaded images will
// be verified against their .sig files from the update server.
func (c *Client) SetPublicKeyPath(path string) {
c.pubKeyPath = path
}
// CheckForUpdate fetches the latest update metadata from the server.
func (c *Client) CheckForUpdate() (*UpdateMetadata, error) {
url := c.serverURL + "/latest.json"
slog.Info("checking for update", "url", url)
resp, err := c.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching update metadata: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update server returned %d", resp.StatusCode)
}
var meta UpdateMetadata
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, fmt.Errorf("parsing update metadata: %w", err)
}
if meta.Version == "" {
return nil, fmt.Errorf("update metadata missing version")
}
return &meta, nil
}
// Download fetches the update files and verifies their checksums.
func (c *Client) Download(meta *UpdateMetadata) (*StagedImage, error) {
if err := os.MkdirAll(c.stageDir, 0o755); err != nil {
return nil, fmt.Errorf("creating stage dir: %w", err)
}
vmlinuzPath := filepath.Join(c.stageDir, "vmlinuz")
initramfsPath := filepath.Join(c.stageDir, "kubesolo-os.gz")
slog.Info("downloading vmlinuz", "url", meta.VmlinuzURL)
if err := c.downloadAndVerify(meta.VmlinuzURL, vmlinuzPath, meta.VmlinuzSHA256); err != nil {
return nil, fmt.Errorf("downloading vmlinuz: %w", err)
}
slog.Info("downloading initramfs", "url", meta.InitramfsURL)
if err := c.downloadAndVerify(meta.InitramfsURL, initramfsPath, meta.InitramfsSHA256); err != nil {
return nil, fmt.Errorf("downloading initramfs: %w", err)
}
// Verify signatures if public key is configured
if c.pubKeyPath != "" {
if err := c.verifySignatures(meta, vmlinuzPath, initramfsPath); err != nil {
os.Remove(vmlinuzPath)
os.Remove(initramfsPath)
return nil, fmt.Errorf("signature verification: %w", err)
}
}
return &StagedImage{
VmlinuzPath: vmlinuzPath,
InitramfsPath: initramfsPath,
Version: meta.Version,
}, nil
}
// Cleanup removes staged update files.
func (c *Client) Cleanup() error {
return os.RemoveAll(c.stageDir)
}
func (c *Client) downloadAndVerify(url, dest, expectedSHA256 string) error {
resp, err := c.httpClient.Get(url)
if err != nil {
return fmt.Errorf("downloading %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %d for %s", resp.StatusCode, url)
}
f, err := os.Create(dest)
if err != nil {
return fmt.Errorf("creating %s: %w", dest, err)
}
defer f.Close()
hasher := sha256.New()
writer := io.MultiWriter(f, hasher)
written, err := io.Copy(writer, resp.Body)
if err != nil {
os.Remove(dest)
return fmt.Errorf("writing %s: %w", dest, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("closing %s: %w", dest, err)
}
// Verify checksum
if expectedSHA256 != "" {
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expectedSHA256 {
os.Remove(dest)
return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", dest, expectedSHA256, actual)
}
slog.Debug("checksum verified", "file", dest, "sha256", actual)
}
slog.Info("downloaded", "file", dest, "size", written)
return nil
}
// verifySignatures downloads .sig files and verifies them against the staged images.
func (c *Client) verifySignatures(meta *UpdateMetadata, vmlinuzPath, initramfsPath string) error {
verifier, err := signing.NewVerifierFromFile(c.pubKeyPath)
if err != nil {
return fmt.Errorf("loading public key: %w", err)
}
// Verify vmlinuz signature
if meta.VmlinuzSigURL != "" {
sigPath := vmlinuzPath + ".sig"
if err := c.downloadToFile(meta.VmlinuzSigURL, sigPath); err != nil {
return fmt.Errorf("downloading vmlinuz signature: %w", err)
}
if err := verifier.VerifyFile(vmlinuzPath, sigPath); err != nil {
return fmt.Errorf("vmlinuz: %w", err)
}
slog.Info("vmlinuz signature verified")
}
// Verify initramfs signature
if meta.InitramfsSigURL != "" {
sigPath := initramfsPath + ".sig"
if err := c.downloadToFile(meta.InitramfsSigURL, sigPath); err != nil {
return fmt.Errorf("downloading initramfs signature: %w", err)
}
if err := verifier.VerifyFile(initramfsPath, sigPath); err != nil {
return fmt.Errorf("initramfs: %w", err)
}
slog.Info("initramfs signature verified")
}
return nil
}
// downloadToFile downloads a URL to a local file (used for signature files).
func (c *Client) downloadToFile(url, dest string) error {
resp, err := c.httpClient.Get(url)
if err != nil {
return fmt.Errorf("downloading %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %d for %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response: %w", err)
}
return os.WriteFile(dest, data, 0o644)
}
// VerifyFile checks the SHA256 checksum of an existing file.
func VerifyFile(path, expectedSHA256 string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return err
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expectedSHA256 {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedSHA256, actual)
}
return nil
}