feat: add security hardening, AppArmor, and ARM64 Raspberry Pi support (Phase 6)
Security hardening: bind kubeconfig server to localhost, mount hardening (noexec/nosuid/nodev on tmpfs), sysctl network hardening, kernel module loading lock after boot, SHA256 checksum verification for downloads, kernel AppArmor + Audit support, complain-mode AppArmor profiles for containerd and kubelet, and security integration test. ARM64 Raspberry Pi support: piCore64 base extraction, RPi kernel build from raspberrypi/linux fork, RPi firmware fetch, SD card image with 4- partition GPT and tryboot A/B mechanism, BootEnv Go interface abstracting GRUB vs RPi boot environments, architecture-aware build scripts, QEMU aarch64 dev VM and boot test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
update/pkg/bootenv/bootenv.go
Normal file
27
update/pkg/bootenv/bootenv.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Package bootenv provides a platform-independent interface for managing
|
||||
// A/B boot environments. It abstracts GRUB (x86_64) and RPi firmware
|
||||
// (ARM64) behind a common interface.
|
||||
package bootenv
|
||||
|
||||
// BootEnv provides read/write access to A/B boot environment variables.
|
||||
type BootEnv interface {
|
||||
// ActiveSlot returns the currently active boot slot ("A" or "B").
|
||||
ActiveSlot() (string, error)
|
||||
// PassiveSlot returns the currently passive boot slot.
|
||||
PassiveSlot() (string, error)
|
||||
// BootCounter returns the current boot counter value.
|
||||
BootCounter() (int, error)
|
||||
// BootSuccess returns whether the last boot was marked successful.
|
||||
BootSuccess() (bool, error)
|
||||
// MarkBootSuccess marks the current boot as successful.
|
||||
MarkBootSuccess() error
|
||||
// ActivateSlot switches the active boot slot and resets the counter.
|
||||
ActivateSlot(slot string) error
|
||||
// ForceRollback switches to the other slot immediately.
|
||||
ForceRollback() error
|
||||
}
|
||||
|
||||
const (
|
||||
SlotA = "A"
|
||||
SlotB = "B"
|
||||
)
|
||||
533
update/pkg/bootenv/bootenv_test.go
Normal file
533
update/pkg/bootenv/bootenv_test.go
Normal file
@@ -0,0 +1,533 @@
|
||||
package bootenv
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createTestGrubenv writes a properly formatted 1024-byte grubenv file.
|
||||
func createTestGrubenv(t *testing.T, dir string, vars map[string]string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "grubenv")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# GRUB Environment Block\n")
|
||||
for k, v := range vars {
|
||||
sb.WriteString(k + "=" + v + "\n")
|
||||
}
|
||||
|
||||
content := sb.String()
|
||||
padding := 1024 - len(content)
|
||||
if padding > 0 {
|
||||
content += strings.Repeat("#", padding)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
// TestGRUBActiveSlot verifies ActiveSlot reads the correct value.
|
||||
func TestGRUBActiveSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "1",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
slot, err := env.ActiveSlot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slot != "A" {
|
||||
t.Errorf("expected A, got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBPassiveSlot verifies PassiveSlot returns the opposite slot.
|
||||
func TestGRUBPassiveSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "0",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
passive, err := env.PassiveSlot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if passive != "B" {
|
||||
t.Errorf("expected B, got %s", passive)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBBootCounter verifies BootCounter reads the correct value.
|
||||
func TestGRUBBootCounter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "2",
|
||||
"boot_success": "0",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
counter, err := env.BootCounter()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter != 2 {
|
||||
t.Errorf("expected 2, got %d", counter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBBootSuccess verifies BootSuccess reads the correct value.
|
||||
func TestGRUBBootSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "1",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
success, err := env.BootSuccess()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !success {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBMarkBootSuccess verifies marking boot as successful.
|
||||
func TestGRUBMarkBootSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "B",
|
||||
"boot_counter": "1",
|
||||
"boot_success": "0",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
if err := env.MarkBootSuccess(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
success, err := env.BootSuccess()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !success {
|
||||
t.Error("expected boot_success=true after MarkBootSuccess")
|
||||
}
|
||||
|
||||
counter, err := env.BootCounter()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter != 3 {
|
||||
t.Errorf("expected boot_counter=3 after MarkBootSuccess, got %d", counter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBActivateSlot verifies slot activation sets correct state.
|
||||
func TestGRUBActivateSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "1",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
if err := env.ActivateSlot("B"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "B" {
|
||||
t.Errorf("expected B, got %s", slot)
|
||||
}
|
||||
|
||||
counter, _ := env.BootCounter()
|
||||
if counter != 3 {
|
||||
t.Errorf("expected counter=3, got %d", counter)
|
||||
}
|
||||
|
||||
success, _ := env.BootSuccess()
|
||||
if success {
|
||||
t.Error("expected boot_success=false after ActivateSlot")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBForceRollback verifies rollback switches to passive slot.
|
||||
func TestGRUBForceRollback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "1",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
if err := env.ForceRollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "B" {
|
||||
t.Errorf("expected B after rollback from A, got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBSlotCycling verifies A->B->A slot switching.
|
||||
func TestGRUBSlotCycling(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "1",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
|
||||
// A -> B
|
||||
if err := env.ActivateSlot("B"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "B" {
|
||||
t.Fatalf("expected B, got %s", slot)
|
||||
}
|
||||
|
||||
// B -> A
|
||||
if err := env.ActivateSlot("A"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, _ = env.ActiveSlot()
|
||||
if slot != "A" {
|
||||
t.Fatalf("expected A, got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRUBActivateInvalidSlot verifies invalid slot is rejected.
|
||||
func TestGRUBActivateInvalidSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "0",
|
||||
})
|
||||
|
||||
env := NewGRUB(path)
|
||||
if err := env.ActivateSlot("C"); err == nil {
|
||||
t.Fatal("expected error for invalid slot")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiActiveSlot verifies ActiveSlot reads from autoboot.txt.
|
||||
func TestRPiActiveSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, false)
|
||||
|
||||
env := NewRPi(dir)
|
||||
slot, err := env.ActiveSlot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slot != "A" {
|
||||
t.Errorf("expected A (partition 2), got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiActiveSlotB verifies slot B with partition 3.
|
||||
func TestRPiActiveSlotB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 3, 2, 3, true)
|
||||
|
||||
env := NewRPi(dir)
|
||||
slot, err := env.ActiveSlot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slot != "B" {
|
||||
t.Errorf("expected B (partition 3), got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiPassiveSlot verifies passive slot is opposite of active.
|
||||
func TestRPiPassiveSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, false)
|
||||
|
||||
env := NewRPi(dir)
|
||||
passive, err := env.PassiveSlot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if passive != "B" {
|
||||
t.Errorf("expected B, got %s", passive)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiBootCounter verifies counter is read from status file.
|
||||
func TestRPiBootCounter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 2, false)
|
||||
|
||||
env := NewRPi(dir)
|
||||
counter, err := env.BootCounter()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter != 2 {
|
||||
t.Errorf("expected 2, got %d", counter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiBootCounterMissingFile verifies default when status file is absent.
|
||||
func TestRPiBootCounterMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Only create autoboot.txt, no boot-status
|
||||
autoboot := "[all]\ntryboot_a_b=1\nboot_partition=2\n[tryboot]\nboot_partition=3\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "autoboot.txt"), []byte(autoboot), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env := NewRPi(dir)
|
||||
counter, err := env.BootCounter()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter != 3 {
|
||||
t.Errorf("expected default counter 3, got %d", counter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiBootSuccess verifies success is read from status file.
|
||||
func TestRPiBootSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, true)
|
||||
|
||||
env := NewRPi(dir)
|
||||
success, err := env.BootSuccess()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !success {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiMarkBootSuccess verifies marking boot success updates both files.
|
||||
func TestRPiMarkBootSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 1, false)
|
||||
|
||||
env := NewRPi(dir)
|
||||
if err := env.MarkBootSuccess(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Active slot should still be A
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "A" {
|
||||
t.Errorf("expected active slot A, got %s", slot)
|
||||
}
|
||||
|
||||
// Boot success should be true
|
||||
success, _ := env.BootSuccess()
|
||||
if !success {
|
||||
t.Error("expected boot_success=true after MarkBootSuccess")
|
||||
}
|
||||
|
||||
// Counter should be reset to 3
|
||||
counter, _ := env.BootCounter()
|
||||
if counter != 3 {
|
||||
t.Errorf("expected counter=3 after MarkBootSuccess, got %d", counter)
|
||||
}
|
||||
|
||||
// [all] boot_partition should be 2 (slot A, making it permanent)
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "autoboot.txt"))
|
||||
if !strings.Contains(string(data), "boot_partition=2") {
|
||||
t.Error("expected [all] boot_partition=2 after MarkBootSuccess")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiActivateSlot verifies slot activation updates tryboot and status.
|
||||
func TestRPiActivateSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, true)
|
||||
|
||||
env := NewRPi(dir)
|
||||
if err := env.ActivateSlot("B"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// [tryboot] should now point to partition 3 (slot B)
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "autoboot.txt"))
|
||||
content := string(data)
|
||||
// Find [tryboot] section and check boot_partition
|
||||
idx := strings.Index(content, "[tryboot]")
|
||||
if idx < 0 {
|
||||
t.Fatal("missing [tryboot] section")
|
||||
}
|
||||
trybootSection := content[idx:]
|
||||
if !strings.Contains(trybootSection, "boot_partition=3") {
|
||||
t.Errorf("expected [tryboot] boot_partition=3, got: %s", trybootSection)
|
||||
}
|
||||
|
||||
// Status should be reset
|
||||
success, _ := env.BootSuccess()
|
||||
if success {
|
||||
t.Error("expected boot_success=false after ActivateSlot")
|
||||
}
|
||||
counter, _ := env.BootCounter()
|
||||
if counter != 3 {
|
||||
t.Errorf("expected counter=3, got %d", counter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiActivateInvalidSlot verifies invalid slot is rejected.
|
||||
func TestRPiActivateInvalidSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, false)
|
||||
|
||||
env := NewRPi(dir)
|
||||
if err := env.ActivateSlot("C"); err == nil {
|
||||
t.Fatal("expected error for invalid slot")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiForceRollback verifies rollback swaps the active slot.
|
||||
func TestRPiForceRollback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, true)
|
||||
|
||||
env := NewRPi(dir)
|
||||
if err := env.ForceRollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// [all] should now point to partition 3 (slot B)
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "B" {
|
||||
t.Errorf("expected B after rollback from A, got %s", slot)
|
||||
}
|
||||
|
||||
// Success should be false
|
||||
success, _ := env.BootSuccess()
|
||||
if success {
|
||||
t.Error("expected boot_success=false after ForceRollback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRPiSlotCycling verifies A->B->A slot switching works.
|
||||
func TestRPiSlotCycling(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createTestAutobootFiles(t, dir, 2, 3, 3, true)
|
||||
|
||||
env := NewRPi(dir)
|
||||
|
||||
// Rollback A -> B
|
||||
if err := env.ForceRollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, _ := env.ActiveSlot()
|
||||
if slot != "B" {
|
||||
t.Fatalf("expected B, got %s", slot)
|
||||
}
|
||||
|
||||
// Rollback B -> A
|
||||
if err := env.ForceRollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, _ = env.ActiveSlot()
|
||||
if slot != "A" {
|
||||
t.Fatalf("expected A, got %s", slot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterfaceCompliance verifies both implementations satisfy BootEnv.
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
grubPath := createTestGrubenv(t, dir, map[string]string{
|
||||
"active_slot": "A",
|
||||
"boot_counter": "3",
|
||||
"boot_success": "0",
|
||||
})
|
||||
|
||||
rpiDir := t.TempDir()
|
||||
createTestAutobootFiles(t, rpiDir, 2, 3, 3, false)
|
||||
|
||||
impls := map[string]BootEnv{
|
||||
"grub": NewGRUB(grubPath),
|
||||
"rpi": NewRPi(rpiDir),
|
||||
}
|
||||
|
||||
for name, env := range impls {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
slot, err := env.ActiveSlot()
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveSlot: %v", err)
|
||||
}
|
||||
if slot != "A" {
|
||||
t.Errorf("ActiveSlot: expected A, got %s", slot)
|
||||
}
|
||||
|
||||
passive, err := env.PassiveSlot()
|
||||
if err != nil {
|
||||
t.Fatalf("PassiveSlot: %v", err)
|
||||
}
|
||||
if passive != "B" {
|
||||
t.Errorf("PassiveSlot: expected B, got %s", passive)
|
||||
}
|
||||
|
||||
counter, err := env.BootCounter()
|
||||
if err != nil {
|
||||
t.Fatalf("BootCounter: %v", err)
|
||||
}
|
||||
if counter != 3 {
|
||||
t.Errorf("BootCounter: expected 3, got %d", counter)
|
||||
}
|
||||
|
||||
success, err := env.BootSuccess()
|
||||
if err != nil {
|
||||
t.Fatalf("BootSuccess: %v", err)
|
||||
}
|
||||
if success {
|
||||
t.Error("BootSuccess: expected false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestAutobootFiles is a helper that writes both autoboot.txt and boot-status.
|
||||
func createTestAutobootFiles(t *testing.T, dir string, allPart, trybootPart, counter int, success bool) {
|
||||
t.Helper()
|
||||
|
||||
autoboot := "[all]\ntryboot_a_b=1\nboot_partition=" + strconv.Itoa(allPart) + "\n"
|
||||
autoboot += "[tryboot]\nboot_partition=" + strconv.Itoa(trybootPart) + "\n"
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "autoboot.txt"), []byte(autoboot), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
successVal := "0"
|
||||
if success {
|
||||
successVal = "1"
|
||||
}
|
||||
status := "boot_counter=" + strconv.Itoa(counter) + "\nboot_success=" + successVal + "\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "boot-status"), []byte(status), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
23
update/pkg/bootenv/grub.go
Normal file
23
update/pkg/bootenv/grub.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package bootenv
|
||||
|
||||
import (
|
||||
"github.com/portainer/kubesolo-os/update/pkg/grubenv"
|
||||
)
|
||||
|
||||
// GRUBEnv implements BootEnv using GRUB environment variables.
|
||||
type GRUBEnv struct {
|
||||
env *grubenv.Env
|
||||
}
|
||||
|
||||
// NewGRUB creates a new GRUB-based BootEnv.
|
||||
func NewGRUB(path string) BootEnv {
|
||||
return &GRUBEnv{env: grubenv.New(path)}
|
||||
}
|
||||
|
||||
func (g *GRUBEnv) ActiveSlot() (string, error) { return g.env.ActiveSlot() }
|
||||
func (g *GRUBEnv) PassiveSlot() (string, error) { return g.env.PassiveSlot() }
|
||||
func (g *GRUBEnv) BootCounter() (int, error) { return g.env.BootCounter() }
|
||||
func (g *GRUBEnv) BootSuccess() (bool, error) { return g.env.BootSuccess() }
|
||||
func (g *GRUBEnv) MarkBootSuccess() error { return g.env.MarkBootSuccess() }
|
||||
func (g *GRUBEnv) ActivateSlot(slot string) error { return g.env.ActivateSlot(slot) }
|
||||
func (g *GRUBEnv) ForceRollback() error { return g.env.ForceRollback() }
|
||||
267
update/pkg/bootenv/rpi.go
Normal file
267
update/pkg/bootenv/rpi.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package bootenv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// RPi partition numbers: slot A = partition 2, slot B = partition 3.
|
||||
rpiSlotAPartition = 2
|
||||
rpiSlotBPartition = 3
|
||||
|
||||
defaultBootCounter = 3
|
||||
)
|
||||
|
||||
// RPiEnv implements BootEnv using Raspberry Pi firmware autoboot.txt.
|
||||
type RPiEnv struct {
|
||||
autobootPath string // path to autoboot.txt
|
||||
statusPath string // path to boot-status file
|
||||
}
|
||||
|
||||
// NewRPi creates a new RPi-based BootEnv.
|
||||
// dir is the directory containing autoboot.txt (typically the boot control
|
||||
// partition mount point).
|
||||
func NewRPi(dir string) BootEnv {
|
||||
return &RPiEnv{
|
||||
autobootPath: filepath.Join(dir, "autoboot.txt"),
|
||||
statusPath: filepath.Join(dir, "boot-status"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RPiEnv) ActiveSlot() (string, error) {
|
||||
partNum, err := r.readAllBootPartition()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading active slot: %w", err)
|
||||
}
|
||||
return partNumToSlot(partNum)
|
||||
}
|
||||
|
||||
func (r *RPiEnv) PassiveSlot() (string, error) {
|
||||
active, err := r.ActiveSlot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if active == SlotA {
|
||||
return SlotB, nil
|
||||
}
|
||||
return SlotA, nil
|
||||
}
|
||||
|
||||
func (r *RPiEnv) BootCounter() (int, error) {
|
||||
status, err := r.readStatus()
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
val, ok := status["boot_counter"]
|
||||
if !ok {
|
||||
return defaultBootCounter, nil
|
||||
}
|
||||
n, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("invalid boot_counter %q: %w", val, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *RPiEnv) BootSuccess() (bool, error) {
|
||||
status, err := r.readStatus()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status["boot_success"] == "1", nil
|
||||
}
|
||||
|
||||
func (r *RPiEnv) MarkBootSuccess() error {
|
||||
// Make the current slot permanent by updating [all] boot_partition
|
||||
active, err := r.ActiveSlot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking boot success: %w", err)
|
||||
}
|
||||
partNum := slotToPartNum(active)
|
||||
if err := r.writeAllBootPartition(partNum); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeStatus(defaultBootCounter, true)
|
||||
}
|
||||
|
||||
func (r *RPiEnv) ActivateSlot(slot string) error {
|
||||
if slot != SlotA && slot != SlotB {
|
||||
return fmt.Errorf("invalid slot: %q (must be A or B)", slot)
|
||||
}
|
||||
partNum := slotToPartNum(slot)
|
||||
// Update [tryboot] to point to the new slot
|
||||
if err := r.writeTrybootPartition(partNum); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeStatus(defaultBootCounter, false)
|
||||
}
|
||||
|
||||
func (r *RPiEnv) ForceRollback() error {
|
||||
passive, err := r.PassiveSlot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap the [all] boot_partition to the other slot
|
||||
partNum := slotToPartNum(passive)
|
||||
if err := r.writeAllBootPartition(partNum); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.writeTrybootPartition(partNum); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeStatus(defaultBootCounter, false)
|
||||
}
|
||||
|
||||
// readAllBootPartition reads the boot_partition value from the [all] section.
|
||||
func (r *RPiEnv) readAllBootPartition() (int, error) {
|
||||
sections, err := r.parseAutoboot()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
val, ok := sections["all"]["boot_partition"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("boot_partition not found in [all] section")
|
||||
}
|
||||
return strconv.Atoi(val)
|
||||
}
|
||||
|
||||
// writeAllBootPartition updates the [all] boot_partition value.
|
||||
func (r *RPiEnv) writeAllBootPartition(partNum int) error {
|
||||
sections, err := r.parseAutoboot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sections["all"] == nil {
|
||||
sections["all"] = make(map[string]string)
|
||||
}
|
||||
sections["all"]["boot_partition"] = strconv.Itoa(partNum)
|
||||
return r.writeAutoboot(sections)
|
||||
}
|
||||
|
||||
// writeTrybootPartition updates the [tryboot] boot_partition value.
|
||||
func (r *RPiEnv) writeTrybootPartition(partNum int) error {
|
||||
sections, err := r.parseAutoboot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sections["tryboot"] == nil {
|
||||
sections["tryboot"] = make(map[string]string)
|
||||
}
|
||||
sections["tryboot"]["boot_partition"] = strconv.Itoa(partNum)
|
||||
return r.writeAutoboot(sections)
|
||||
}
|
||||
|
||||
// parseAutoboot reads autoboot.txt into a map of section -> key=value pairs.
|
||||
func (r *RPiEnv) parseAutoboot() (map[string]map[string]string, error) {
|
||||
data, err := os.ReadFile(r.autobootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading autoboot.txt: %w", err)
|
||||
}
|
||||
|
||||
sections := make(map[string]map[string]string)
|
||||
currentSection := ""
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = line[1 : len(line)-1]
|
||||
if sections[currentSection] == nil {
|
||||
sections[currentSection] = make(map[string]string)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 && currentSection != "" {
|
||||
sections[currentSection][strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// writeAutoboot writes sections back to autoboot.txt.
|
||||
// Section order: [all] first, then [tryboot].
|
||||
func (r *RPiEnv) writeAutoboot(sections map[string]map[string]string) error {
|
||||
var sb strings.Builder
|
||||
|
||||
// Write [all] section first
|
||||
if all, ok := sections["all"]; ok {
|
||||
sb.WriteString("[all]\n")
|
||||
for k, v := range all {
|
||||
sb.WriteString(k + "=" + v + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Write [tryboot] section
|
||||
if tryboot, ok := sections["tryboot"]; ok {
|
||||
sb.WriteString("[tryboot]\n")
|
||||
for k, v := range tryboot {
|
||||
sb.WriteString(k + "=" + v + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(r.autobootPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
// readStatus reads the boot-status key=value file.
|
||||
func (r *RPiEnv) readStatus() (map[string]string, error) {
|
||||
data, err := os.ReadFile(r.statusPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Return defaults if status file doesn't exist yet
|
||||
return map[string]string{
|
||||
"boot_counter": strconv.Itoa(defaultBootCounter),
|
||||
"boot_success": "0",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading boot-status: %w", err)
|
||||
}
|
||||
|
||||
status := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
status[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// writeStatus writes boot_counter and boot_success to the status file.
|
||||
func (r *RPiEnv) writeStatus(counter int, success bool) error {
|
||||
successVal := "0"
|
||||
if success {
|
||||
successVal = "1"
|
||||
}
|
||||
content := fmt.Sprintf("boot_counter=%d\nboot_success=%s\n", counter, successVal)
|
||||
return os.WriteFile(r.statusPath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func partNumToSlot(partNum int) (string, error) {
|
||||
switch partNum {
|
||||
case rpiSlotAPartition:
|
||||
return SlotA, nil
|
||||
case rpiSlotBPartition:
|
||||
return SlotB, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown partition number %d (expected %d or %d)", partNum, rpiSlotAPartition, rpiSlotBPartition)
|
||||
}
|
||||
}
|
||||
|
||||
func slotToPartNum(slot string) int {
|
||||
if slot == SlotB {
|
||||
return rpiSlotBPartition
|
||||
}
|
||||
return rpiSlotAPartition
|
||||
}
|
||||
Reference in New Issue
Block a user