Files
kubesolo-os/update/pkg/partition/partition.go
Adolfo Delorenzo 8d25e1890e feat: add A/B partition updates with GRUB and Go update agent (Phase 3)
Implement atomic OS updates via A/B partition scheme with automatic
rollback. GRUB bootloader manages slot selection with a 3-attempt
boot counter that auto-rolls back on repeated health check failures.

GRUB boot config:
- A/B slot selection with boot_counter/boot_success env vars
- Automatic rollback when counter reaches 0 (3 failed boots)
- Debug, emergency shell, and manual slot-switch menu entries

Disk image (refactored):
- 4-partition GPT layout: EFI + System A + System B + Data
- GRUB EFI/BIOS installation with graceful fallbacks
- Both system partitions populated during image creation

Update agent (Go, zero external deps):
- pkg/grubenv: read/write GRUB env vars (grub-editenv + manual fallback)
- pkg/partition: find/mount/write system partitions by label
- pkg/image: HTTP download with SHA256 verification
- pkg/health: post-boot checks (containerd, API server, node Ready)
- 6 CLI commands: check, apply, activate, rollback, healthcheck, status
- 37 unit tests across all 4 packages

Deployment:
- K8s CronJob for automatic update checks (every 6 hours)
- ConfigMap for update server URL
- Health check Job for post-boot verification

Build pipeline:
- build-update-agent.sh compiles static Linux binary (~5.9 MB)
- inject-kubesolo.sh includes update agent in initramfs
- Makefile: build-update-agent, test-update-agent, test-update targets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:12:46 -06:00

140 lines
3.9 KiB
Go

// Package partition detects and manages A/B system partitions.
//
// It identifies System A and System B partitions by label (KSOLOA, KSOLOB)
// and provides mount/write operations for the update process.
package partition
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
LabelSystemA = "KSOLOA"
LabelSystemB = "KSOLOB"
LabelData = "KSOLODATA"
LabelEFI = "KSOLOEFI"
)
// Info contains information about a partition.
type Info struct {
Device string // e.g. /dev/sda2
Label string // e.g. KSOLOA
MountPoint string // current mount point, empty if not mounted
Slot string // "A" or "B"
}
// FindByLabel locates a block device by its filesystem label.
func FindByLabel(label string) (string, error) {
cmd := exec.Command("blkid", "-L", label)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("partition with label %q not found: %w", label, err)
}
return strings.TrimSpace(string(output)), nil
}
// GetSlotPartition returns the partition info for the given slot ("A" or "B").
func GetSlotPartition(slot string) (*Info, error) {
var label string
switch slot {
case "A":
label = LabelSystemA
case "B":
label = LabelSystemB
default:
return nil, fmt.Errorf("invalid slot: %q", slot)
}
dev, err := FindByLabel(label)
if err != nil {
return nil, err
}
return &Info{
Device: dev,
Label: label,
Slot: slot,
}, nil
}
// MountReadOnly mounts a partition read-only at the given mount point.
func MountReadOnly(dev, mountPoint string) error {
if err := os.MkdirAll(mountPoint, 0o755); err != nil {
return fmt.Errorf("creating mount point: %w", err)
}
cmd := exec.Command("mount", "-o", "ro", dev, mountPoint)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mounting %s at %s: %w\n%s", dev, mountPoint, err, output)
}
slog.Debug("mounted", "device", dev, "mountpoint", mountPoint, "mode", "ro")
return nil
}
// MountReadWrite mounts a partition read-write at the given mount point.
func MountReadWrite(dev, mountPoint string) error {
if err := os.MkdirAll(mountPoint, 0o755); err != nil {
return fmt.Errorf("creating mount point: %w", err)
}
cmd := exec.Command("mount", dev, mountPoint)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mounting %s at %s: %w\n%s", dev, mountPoint, err, output)
}
slog.Debug("mounted", "device", dev, "mountpoint", mountPoint, "mode", "rw")
return nil
}
// Unmount unmounts a mount point.
func Unmount(mountPoint string) error {
cmd := exec.Command("umount", mountPoint)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unmounting %s: %w\n%s", mountPoint, err, output)
}
return nil
}
// ReadVersion reads the version file from a mounted system partition.
func ReadVersion(mountPoint string) (string, error) {
data, err := os.ReadFile(filepath.Join(mountPoint, "version"))
if err != nil {
return "", fmt.Errorf("reading version: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// WriteSystemImage copies vmlinuz and initramfs to a mounted partition.
func WriteSystemImage(mountPoint, vmlinuzPath, initramfsPath, version string) error {
// Copy vmlinuz
if err := copyFile(vmlinuzPath, filepath.Join(mountPoint, "vmlinuz")); err != nil {
return fmt.Errorf("writing vmlinuz: %w", err)
}
// Copy initramfs
if err := copyFile(initramfsPath, filepath.Join(mountPoint, "kubesolo-os.gz")); err != nil {
return fmt.Errorf("writing initramfs: %w", err)
}
// Write version
if err := os.WriteFile(filepath.Join(mountPoint, "version"), []byte(version+"\n"), 0o644); err != nil {
return fmt.Errorf("writing version: %w", err)
}
// Sync to ensure data is flushed to disk
exec.Command("sync").Run()
slog.Info("system image written", "mountpoint", mountPoint, "version", version)
return nil
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}