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>
This commit is contained in:
139
update/pkg/partition/partition.go
Normal file
139
update/pkg/partition/partition.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// 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)
|
||||
}
|
||||
129
update/pkg/partition/partition_test.go
Normal file
129
update/pkg/partition/partition_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package partition
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
versionFile := filepath.Join(dir, "version")
|
||||
if err := os.WriteFile(versionFile, []byte("1.2.3\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version, err := ReadVersion(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != "1.2.3" {
|
||||
t.Errorf("expected 1.2.3, got %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := ReadVersion(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing version file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSystemImage(t *testing.T) {
|
||||
mountPoint := t.TempDir()
|
||||
srcDir := t.TempDir()
|
||||
|
||||
// Create source files
|
||||
vmlinuzPath := filepath.Join(srcDir, "vmlinuz")
|
||||
initramfsPath := filepath.Join(srcDir, "kubesolo-os.gz")
|
||||
|
||||
if err := os.WriteFile(vmlinuzPath, []byte("kernel data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(initramfsPath, []byte("initramfs data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := WriteSystemImage(mountPoint, vmlinuzPath, initramfsPath, "2.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify files were copied
|
||||
data, err := os.ReadFile(filepath.Join(mountPoint, "vmlinuz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "kernel data" {
|
||||
t.Errorf("vmlinuz content mismatch")
|
||||
}
|
||||
|
||||
data, err = os.ReadFile(filepath.Join(mountPoint, "kubesolo-os.gz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "initramfs data" {
|
||||
t.Errorf("initramfs content mismatch")
|
||||
}
|
||||
|
||||
// Verify version file
|
||||
version, err := ReadVersion(mountPoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != "2.0.0" {
|
||||
t.Errorf("expected version 2.0.0, got %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
|
||||
if err := os.WriteFile(src, []byte("test content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "test content" {
|
||||
t.Errorf("copy content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := copyFile("/nonexistent", filepath.Join(dir, "dst"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSlotPartitionInvalid(t *testing.T) {
|
||||
_, err := GetSlotPartition("C")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if LabelSystemA != "KSOLOA" {
|
||||
t.Errorf("unexpected LabelSystemA: %s", LabelSystemA)
|
||||
}
|
||||
if LabelSystemB != "KSOLOB" {
|
||||
t.Errorf("unexpected LabelSystemB: %s", LabelSystemB)
|
||||
}
|
||||
if LabelData != "KSOLODATA" {
|
||||
t.Errorf("unexpected LabelData: %s", LabelData)
|
||||
}
|
||||
if LabelEFI != "KSOLOEFI" {
|
||||
t.Errorf("unexpected LabelEFI: %s", LabelEFI)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user