penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 578b3e6a6f8950253c3d75146656562a311194e0e498b4567ef4144006201b8b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-04-06T15:49:39Z
subjectInitial upgrade flow for control-plane
commit 578b3e6a6f8950253c3d75146656562a311194e0e498b4567ef4144006201b8b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-04-06T15:49:39Z

    Initial upgrade flow for control-plane
---
 README.md                                          |   2 +-
 alpine/rootfs-extra/etc/containers/storage.conf    |  10 +-
 build.env                                          |   4 +-
 clitools/makefile                                  |   2 +-
 .../pkg/apis/monok8s/v1alpha1/groupversion_info.go |  22 ++-
 clitools/pkg/bootstrap/registry.go                 |   6 +-
 clitools/pkg/bootstrap/runner.go                   |  20 +++
 clitools/pkg/cmd/agent/agent.go                    |  38 +++-
 clitools/pkg/controller/osimage/helpers.go         |  46 +++++
 clitools/pkg/controller/osupgrade/handler.go       |   4 +-
 clitools/pkg/node/crio.go                          |  60 ++++++-
 clitools/pkg/node/fs.go                            |  32 ++++
 clitools/pkg/node/kubeadm.go                       |   5 -
 clitools/pkg/node/kubeadm_upgrade.go               |  49 ++++++
 clitools/pkg/node/mounts.go                        | 194 +++++++++++++++++++++
 clitools/pkg/node/uboot/ab.go                      |  69 +++++++-
 clitools/pkg/node/uboot/env_writer.go              |  11 +-
 docs/flashing-usb.md                               |  20 ++-
 initramfs/rootfs-extra/init                        |  11 +-
 19 files changed, 563 insertions(+), 42 deletions(-)

diff --git a/README.md b/README.md
index ae7a42b..dc4fc4e 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ https://docs.mono.si/gateway-development-kit/getting-started
 
  * USE AT YOUR OWN RISKS. I leverage ChatGPT heavily for this.
 
-### Table of Contents
+## Table of Contents
 1. Flashing
    - [USB](docs/flashing-usb.md)
    - [Over network (tftp)](docs/flashing-network.md)
diff --git a/alpine/rootfs-extra/etc/containers/storage.conf b/alpine/rootfs-extra/etc/containers/storage.conf
index aae2d5e..ceab0b3 100644
--- a/alpine/rootfs-extra/etc/containers/storage.conf
+++ b/alpine/rootfs-extra/etc/containers/storage.conf
@@ -1,9 +1 @@
-[storage]
-driver = "overlay"
-runroot = "/run/containers/storage"
-graphroot = "/var/lib/containers/storage"
-
-[storage.options]
-additionalimagestores = [
-  "/usr/lib/monok8s/imagestore"
-]
+# Generated file. DO NOT MODIFY.
diff --git a/build.env b/build.env
index ecffdcb..49905dd 100644
--- a/build.env
+++ b/build.env
@@ -5,8 +5,8 @@ TAG=dev
 
 # The Linux kernel, from NXP
 NXP_VERSION=lf-6.18.2-1.0.0
-CRIO_VERSION=cri-o.arm64.v1.33.3
-KUBE_VERSION=v1.33.3
+CRIO_VERSION=cri-o.arm64.v1.33.10
+KUBE_VERSION=v1.33.10
 
 # Mono's tutorial said fsl-ls1046a-rdb.dtb but our shipped board is not that one
 # We need fsl-ls1046a-rdb-sdk.dtb here
diff --git a/clitools/makefile b/clitools/makefile
index d353f3b..4d3fdbb 100644
--- a/clitools/makefile
+++ b/clitools/makefile
@@ -3,7 +3,7 @@ VERSION ?= dev
 UBOOT_VERSION=v2026.01
 
 # Target kube version
-KUBE_VERSION ?= v1.35.0
+KUBE_VERSION ?= v1.33.3
 
 GIT_REV := $(shell git rev-parse HEAD)
 
diff --git a/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go b/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go
index 5966af6..948cef2 100644
--- a/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go
+++ b/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go
@@ -9,15 +9,19 @@ import (
 )
 
 var (
-	Group           = "monok8s.io"
-	Version         = "v1alpha1"
-	MonoKSConfigCRD = "monoksconfigs.monok8s.io"
-	OSUpgradeCRD    = "osupgrades.monok8s.io"
-	APIVersion      = "monok8s.io/v1alpha1"
-	Label           = "monok8s.io/label"
-	Annotation      = "monok8s.io/annotation"
-	ControlAgentKey = "monok8s.io/control-agent"
-	CatalogURL      = "https://example.com/monok8s.io/v1alpha1/catalog.yaml"
+	Group      = "monok8s.io"
+	Version    = "v1alpha1"
+	APIVersion = "monok8s.io/v1alpha1"
+
+	AltPartDeviceLink = "/dev/mksaltpart"
+	Annotation        = "monok8s.io/annotation"
+	BootStateFile     = "/run/monok8s/boot-state.env"
+	CatalogURL        = "https://example.com/monok8s.io/v1alpha1/catalog.yaml"
+	ControlAgentKey   = "monok8s.io/control-agent"
+	EnvConfigDir      = "/opt/monok8s/config"
+	Label             = "monok8s.io/label"
+	MonoKSConfigCRD   = "monoksconfigs.monok8s.io"
+	OSUpgradeCRD      = "osupgrades.monok8s.io"
 )
 
 var (
diff --git a/clitools/pkg/bootstrap/registry.go b/clitools/pkg/bootstrap/registry.go
index 5c4a52b..06b05e6 100644
--- a/clitools/pkg/bootstrap/registry.go
+++ b/clitools/pkg/bootstrap/registry.go
@@ -27,21 +27,25 @@ func NewRegistry(ctx *node.NodeContext) *Registry {
 			"ApplyLocalNodeMetadataIfPossible":       node.ApplyLocalNodeMetadataIfPossible,
 			"CheckForVersionSkew":                    node.CheckForVersionSkew,
 			"ClassifyBootstrapAction":                node.ClassifyBootstrapAction,
+			"ConfigureABBoot":                        uboot.ConfigureABBoot,
 			"ConfigureDNS":                           node.ConfigureDNS(netCfg),
 			"ConfigureDefaultCNI":                    node.ConfigureDefaultCNI,
 			"ConfigureHostname":                      node.ConfigureHostname(netCfg),
 			"ConfigureMgmtInterface":                 node.ConfigureMgmtInterface(netCfg),
-			"ConfigureABBoot":                        uboot.ConfigureABBoot,
 			"ConfigureUBootCommands":                 uboot.ConfigureUBootCommands,
 			"DetectLocalClusterState":                node.DetectLocalClusterState,
+			"EngageControlGate":                      node.EngageControlGate,
 			"EnsureIPForward":                        node.EnsureIPForward,
+			"MountAltImageStore":                     node.MountAltImageStore,
 			"ReconcileControlPlane":                  node.ReconcileControlPlane,
 			"ReconcileWorker":                        node.ReconcileWorker,
+			"ReleaseControlGate":                     node.ReleaseControlGate,
 			"RunKubeadmInit":                         node.RunKubeadmInit,
 			"RunKubeadmJoin":                         node.RunKubeadmJoin,
 			"RunKubeadmUpgradeApply":                 node.RunKubeadmUpgradeApply,
 			"RunKubeadmUpgradeNode":                  node.RunKubeadmUpgradeNode,
 			"StartCRIO":                              node.StartCRIO,
+			"UnmountAltImageStore":                   node.UnmountAltImageStore,
 			"ValidateNodeIPAndAPIServerReachability": node.ValidateNodeIPAndAPIServerReachability,
 			"ValidateRequiredImagesPresent":          node.ValidateRequiredImagesPresent,
 			"WaitForExistingClusterIfNeeded":         node.WaitForExistingClusterIfNeeded,
diff --git a/clitools/pkg/bootstrap/runner.go b/clitools/pkg/bootstrap/runner.go
index d951046..c780596 100644
--- a/clitools/pkg/bootstrap/runner.go
+++ b/clitools/pkg/bootstrap/runner.go
@@ -62,6 +62,16 @@ func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
 				Name:   "Configure default CNI",
 				Desc:   "Install or configure default container networking (CNI bridge, IPAM, etc.)",
 			},
+			{
+				RegKey: "MountAltImageStore",
+				Name:   "Mount alt image store for CRI-O. Needed for upgrade.",
+				Desc:   "Will be unmount after kubeadm upgrade apply",
+			},
+			{
+				RegKey: "EngageControlGate",
+				Name:   "Engage the control gate",
+				Desc:   "Prevents agent polling resources prematurely",
+			},
 			{
 				RegKey: "StartCRIO",
 				Name:   "Start CRI-O runtime",
@@ -127,6 +137,11 @@ func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
 				Name:   "Run kubeadm upgrade node",
 				Desc:   "Upgrade node components (kubelet, config) to match control plane",
 			},
+			{
+				RegKey: "UnmountAltImageStore",
+				Name:   "Unmount alt image store",
+				Desc:   "Rewrite CRIO storage.conf. Then restart CRIO. Then unmount.",
+			},
 			{
 				RegKey: "ApplyLocalNodeMetadataIfPossible",
 				Name:   "Apply node metadata",
@@ -147,6 +162,11 @@ func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
 				Name:   "Apply daemonset for control agent",
 				Desc:   "Control agent handles OSUpgrade resources",
 			},
+			{
+				RegKey: "ReleaseControlGate",
+				Name:   "Release the control gate",
+				Desc:   "Allow agent to start polling resources",
+			},
 		},
 	}
 }
diff --git a/clitools/pkg/cmd/agent/agent.go b/clitools/pkg/cmd/agent/agent.go
index cc5e676..47a2f4b 100644
--- a/clitools/pkg/cmd/agent/agent.go
+++ b/clitools/pkg/cmd/agent/agent.go
@@ -3,6 +3,8 @@ package agent
 import (
 	"context"
 	"fmt"
+	"os"
+	"path/filepath"
 	"time"
 
 	"github.com/spf13/cobra"
@@ -50,6 +52,11 @@ func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command {
 				return fmt.Errorf("node name is empty in rendered config")
 			}
 
+			ctx := cmd.Context()
+			if err := waitForControlGate(ctx, envFile, 2*time.Second); err != nil {
+				return fmt.Errorf("wait for control gate to release: %w", err)
+			}
+
 			klog.InfoS("starting agent",
 				"node", cfg.Spec.NodeName,
 				"namespace", namespace,
@@ -62,7 +69,6 @@ func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command {
 				return fmt.Errorf("create kube clients: %w", err)
 			}
 
-			ctx := cmd.Context()
 			return runPollLoop(ctx, clients, namespace, cfg.Spec.NodeName, pollInterval)
 		},
 	}
@@ -74,6 +80,36 @@ func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command {
 	return cmd
 }
 
+func waitForControlGate(ctx context.Context, envFile string, pollInterval time.Duration) error {
+	dir := filepath.Dir(envFile)
+	marker := filepath.Join(dir, ".control-gate")
+
+	if pollInterval <= 0 {
+		pollInterval = 2 * time.Second
+	}
+
+	ticker := time.NewTicker(pollInterval)
+	defer ticker.Stop()
+
+	for {
+		_, err := os.Stat(marker)
+		if err == nil {
+			klog.InfoS("Control gate is present; waiting before starting poll loop", "path", marker)
+		} else if os.IsNotExist(err) {
+			klog.InfoS("Control gate not present; starting poll loop", "path", marker)
+			return nil
+		} else {
+			return fmt.Errorf("stat upgrade marker %s: %w", marker, err)
+		}
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-ticker.C:
+		}
+	}
+}
+
 func runPollLoop(ctx context.Context, clients *kube.Clients, namespace, nodeName string, interval time.Duration) error {
 	gvr := schema.GroupVersionResource{
 		Group:    monov1alpha1.Group,
diff --git a/clitools/pkg/controller/osimage/helpers.go b/clitools/pkg/controller/osimage/helpers.go
index 505c573..efd5270 100644
--- a/clitools/pkg/controller/osimage/helpers.go
+++ b/clitools/pkg/controller/osimage/helpers.go
@@ -1,5 +1,20 @@
 package osimage
 
+import (
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+)
+
+var (
+	bootStateOnce sync.Once
+	bootState     map[string]string
+	bootStateErr  error
+)
+
 func PercentOf(done, total int64) int64 {
 	if total <= 0 {
 		return 0
@@ -13,3 +28,34 @@ func PercentOf(done, total int64) int64 {
 	}
 	return p
 }
+
+func ReadBootState() (map[string]string, error) {
+	bootStateOnce.Do(func() {
+		data, err := os.ReadFile(monov1alpha1.BootStateFile)
+		if err != nil {
+			bootStateErr = err
+			return
+		}
+
+		out := make(map[string]string)
+
+		for _, line := range strings.Split(string(data), "\n") {
+			line = strings.TrimSpace(line)
+			if line == "" || strings.HasPrefix(line, "#") {
+				continue
+			}
+
+			k, v, ok := strings.Cut(line, "=")
+			if !ok {
+				bootStateErr = fmt.Errorf("invalid line: %q", line)
+				return
+			}
+
+			out[strings.TrimSpace(k)] = strings.TrimSpace(v)
+		}
+
+		bootState = out
+	})
+
+	return bootState, bootStateErr
+}
diff --git a/clitools/pkg/controller/osupgrade/handler.go b/clitools/pkg/controller/osupgrade/handler.go
index 76fec0a..fc1231d 100644
--- a/clitools/pkg/controller/osupgrade/handler.go
+++ b/clitools/pkg/controller/osupgrade/handler.go
@@ -111,7 +111,7 @@ func handleOSUpgradeLocked(ctx context.Context, clients *kube.Clients,
 		"resolvedTarget", plan.ResolvedTarget,
 		"steps", len(plan.Path),
 		"currentVersion", buildinfo.KubeVersion,
-		"fSHA256irstVersion", first.Version,
+		"firstVersion", first.Version,
 		"firstURL", first.URL,
 		"size", first.Size,
 	)
@@ -126,7 +126,7 @@ func handleOSUpgradeLocked(ctx context.Context, clients *kube.Clients,
 
 	imageOptions := osimage.ApplyOptions{
 		URL:               first.URL,
-		TargetPath:        "/dev/mksaltpart",
+		TargetPath:        monov1alpha1.AltPartDeviceLink,
 		ExpectedRawSHA256: imageSHA,
 		ExpectedRawSize:   first.Size,
 		BufferSize:        6 * 1024 * 1024,
diff --git a/clitools/pkg/node/crio.go b/clitools/pkg/node/crio.go
index 1988eb3..a614476 100644
--- a/clitools/pkg/node/crio.go
+++ b/clitools/pkg/node/crio.go
@@ -5,9 +5,14 @@ import (
 	"fmt"
 	"os"
 	"strings"
+	"time"
 
-	"k8s.io/klog/v2"
 	system "example.com/monok8s/pkg/system"
+	"k8s.io/klog/v2"
+)
+
+const (
+	storageConfPath = "/etc/containers/storage.conf"
 )
 
 func ConfigureDefaultCNI(ctx context.Context, n *NodeContext) error {
@@ -60,3 +65,56 @@ func ConfigureDefaultCNI(ctx context.Context, n *NodeContext) error {
 func StartCRIO(ctx context.Context, n *NodeContext) error {
 	return system.EnsureServiceRunning(ctx, n.SystemRunner, "crio")
 }
+
+func RestartCRIO(ctx context.Context, nctx *NodeContext) error {
+	_, err := nctx.SystemRunner.RunWithOptions(
+		ctx,
+		"rc-service",
+		[]string{"crio", "restart"},
+		system.RunOptions{
+			Timeout:      60 * time.Second,
+			OnStdoutLine: func(line string) { klog.Infof("[crio] %s", line) },
+			OnStderrLine: func(line string) { klog.Infof("[crio] %s", line) },
+		},
+	)
+	return err
+}
+
+func writeCRIOStorageConfig(ctx context.Context, nctx *NodeContext, altSource string) error {
+
+	additionalStores := []string{
+		"/usr/lib/monok8s/imagestore",
+	}
+
+	if altSource != "" {
+		additionalStores = append(additionalStores, altSource)
+	}
+
+	var b strings.Builder
+	b.WriteString("# Generated File. DO NOT MODIFY.\n")
+	b.WriteString("[storage]\n")
+	b.WriteString("driver = \"overlay\"\n")
+	b.WriteString("runroot = \"/run/containers/storage\"\n")
+	b.WriteString("graphroot = \"/var/lib/containers/storage\"\n\n")
+	b.WriteString("[storage.options]\n")
+	b.WriteString("additionalimagestores = [\n")
+	for _, s := range additionalStores {
+		b.WriteString(fmt.Sprintf("  %q,\n", s))
+	}
+	b.WriteString("]\n")
+
+	content := b.String()
+
+	path := storageConfPath
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
+		return fmt.Errorf("write temp storage.conf: %w", err)
+	}
+	if err := os.Rename(tmp, path); err != nil {
+		_ = os.Remove(tmp)
+		return fmt.Errorf("replace storage.conf: %w", err)
+	}
+
+	klog.InfoS("wrote CRI-O storage config", "path", path, "additionalImageStores", additionalStores)
+	return nil
+}
diff --git a/clitools/pkg/node/fs.go b/clitools/pkg/node/fs.go
new file mode 100644
index 0000000..196422a
--- /dev/null
+++ b/clitools/pkg/node/fs.go
@@ -0,0 +1,32 @@
+package node
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+)
+
+func EngageControlGate(ctx context.Context, nctx *NodeContext) error {
+	gateFile := filepath.Join(monov1alpha1.EnvConfigDir, ".control-gate")
+
+	f, err := os.OpenFile(gateFile, os.O_CREATE|os.O_WRONLY, 0o644)
+	if err != nil {
+		return fmt.Errorf("touch %s: %w", gateFile, err)
+	}
+	defer f.Close()
+
+	return nil
+}
+
+func ReleaseControlGate(ctx context.Context, nctx *NodeContext) error {
+	gateFile := filepath.Join(monov1alpha1.EnvConfigDir, ".control-gate")
+
+	if err := os.Remove(gateFile); err != nil {
+		return fmt.Errorf("relate control gate: %w", err)
+	}
+
+	return nil
+}
diff --git a/clitools/pkg/node/kubeadm.go b/clitools/pkg/node/kubeadm.go
index cc01781..dcd08f5 100644
--- a/clitools/pkg/node/kubeadm.go
+++ b/clitools/pkg/node/kubeadm.go
@@ -732,11 +732,6 @@ func RunKubeadmJoin(ctx context.Context, nctx *NodeContext) error {
 	return nil
 }
 
-func RunKubeadmUpgradeApply(context.Context, *NodeContext) error {
-	klog.Info("run_kubeadm_upgrade_apply: TODO implement kubeadm upgrade apply")
-	return nil
-}
-
 func RunKubeadmUpgradeNode(context.Context, *NodeContext) error {
 	klog.Info("run_kubeadm_upgrade_node: TODO implement kubeadm upgrade node")
 	return nil
diff --git a/clitools/pkg/node/kubeadm_upgrade.go b/clitools/pkg/node/kubeadm_upgrade.go
new file mode 100644
index 0000000..ef47477
--- /dev/null
+++ b/clitools/pkg/node/kubeadm_upgrade.go
@@ -0,0 +1,49 @@
+package node
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"k8s.io/klog/v2"
+
+	system "example.com/monok8s/pkg/system"
+)
+
+func RunKubeadmUpgradeApply(ctx context.Context, nctx *NodeContext) error {
+
+	if nctx.BootstrapState == nil {
+		return errors.New("BootstrapState is nil. Please run earlier steps first")
+	}
+
+	if nctx.BootstrapState.Action != BootstrapActionUpgradeControlPlane {
+		klog.V(4).Infof("skipped for %s", nctx.BootstrapState.Action)
+		return nil
+	}
+
+	if strings.TrimSpace(tmpKubeadmInitConf) == "" {
+		return fmt.Errorf("tmp kubeadm config path is empty")
+	}
+
+	_, err := nctx.SystemRunner.RunWithOptions(
+		ctx,
+		"kubeadm",
+		[]string{"upgrade", "apply", "-y", nctx.Config.Spec.KubernetesVersion},
+		system.RunOptions{
+			Timeout: 15 * time.Minute,
+			OnStdoutLine: func(line string) {
+				klog.Infof("[kubeadm] %s", line)
+			},
+			OnStderrLine: func(line string) {
+				klog.Infof("[kubeadm] %s", line)
+			},
+		},
+	)
+	if err != nil {
+		return fmt.Errorf("run kubeadm upgrade apply: %w", err)
+	}
+
+	return nil
+}
diff --git a/clitools/pkg/node/mounts.go b/clitools/pkg/node/mounts.go
new file mode 100644
index 0000000..a99dc65
--- /dev/null
+++ b/clitools/pkg/node/mounts.go
@@ -0,0 +1,194 @@
+package node
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+	system "example.com/monok8s/pkg/system"
+	"k8s.io/klog/v2"
+)
+
+const (
+	altRootMount     = "/run/altrootfs"
+	altImageStoreSrc = "/run/altrootfs/usr/lib/monok8s/imagestore"
+)
+
+func MountAltImageStore(ctx context.Context, nctx *NodeContext) error {
+	// Make mountpoints first.
+	if err := os.MkdirAll(altRootMount, 0o755); err != nil {
+		return fmt.Errorf("mkdir %s: %w", altRootMount, err)
+	}
+
+	altDev := monov1alpha1.AltPartDeviceLink
+	if mounted, err := isMounted(altRootMount); err != nil {
+		return fmt.Errorf("check mount %s: %w", altRootMount, err)
+	} else if !mounted {
+		klog.InfoS("mounting alt rootfs", "device", altDev, "target", altRootMount)
+		if _, err := nctx.SystemRunner.RunWithOptions(
+			ctx,
+			"mount",
+			[]string{"-o", "ro", altDev, altRootMount},
+			system.RunOptions{Timeout: 30 * time.Second},
+		); err != nil {
+			return fmt.Errorf("mount alt rootfs %s on %s: %w", altDev, altRootMount, err)
+		}
+	}
+
+	// If the alt imagestore doesn't exist, don't fail hard unless you want strict behavior.
+	st, err := os.Stat(altImageStoreSrc)
+	if err != nil {
+
+		// Unmount immediately
+		_ = safeUnmount(ctx, nctx, altRootMount)
+
+		if os.IsNotExist(err) {
+			klog.InfoS(
+				"alt imagestore not found; proceeding without it",
+				"path", altImageStoreSrc,
+			)
+			err = writeCRIOStorageConfig(ctx, nctx, "")
+			if err != nil {
+				return err
+			}
+			return nil
+		}
+
+		return fmt.Errorf("stat alt imagestore %s: %w", altImageStoreSrc, err)
+	}
+
+	if !st.IsDir() {
+		return fmt.Errorf("alt imagestore exists but is not a directory: %s", altImageStoreSrc)
+	}
+
+	err = writeCRIOStorageConfig(ctx, nctx, altImageStoreSrc)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func UnmountAltImageStore(ctx context.Context, nctx *NodeContext) error {
+
+	mounted, err := isMounted(altRootMount)
+	if err != nil {
+		return err
+	}
+
+	if mounted {
+		if err := writeCRIOStorageConfig(ctx, nctx, ""); err != nil {
+			return err
+		}
+		err := RestartCRIO(ctx, nctx)
+		if err != nil {
+			return err
+		}
+	}
+
+	var errs []string
+
+	if err := safeUnmount(ctx, nctx, altRootMount); err != nil {
+		errs = append(errs, err.Error())
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf(strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+func isMounted(path string) (bool, error) {
+	f, err := os.Open("/proc/self/mountinfo")
+	if err != nil {
+		return false, fmt.Errorf("open /proc/self/mountinfo: %w", err)
+	}
+	defer f.Close()
+
+	target := filepath.Clean(path)
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if len(fields) < 5 {
+			continue
+		}
+
+		mountPoint, err := unescapeMountInfo(fields[4])
+		if err != nil {
+			return false, fmt.Errorf("decode mountpoint %q: %w", fields[4], err)
+		}
+
+		if filepath.Clean(mountPoint) == target {
+			return true, nil
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return false, fmt.Errorf("scan /proc/self/mountinfo: %w", err)
+	}
+
+	return false, nil
+}
+
+func safeUnmount(ctx context.Context, nctx *NodeContext, path string) error {
+	mounted, err := isMounted(path)
+	if err != nil {
+		return fmt.Errorf("check mountpoint %s: %w", path, err)
+	}
+	if !mounted {
+		return nil
+	}
+
+	klog.InfoS("unmounting", "target", path)
+	_, err = nctx.SystemRunner.RunWithOptions(
+		ctx,
+		"umount",
+		[]string{path},
+		system.RunOptions{Timeout: 30 * time.Second},
+	)
+	if err != nil {
+		return fmt.Errorf("umount %s: %w", path, err)
+	}
+	return nil
+}
+
+func unescapeMountInfo(s string) (string, error) {
+	var b strings.Builder
+	b.Grow(len(s))
+
+	for i := 0; i < len(s); i++ {
+		if s[i] != '\\' {
+			b.WriteByte(s[i])
+			continue
+		}
+
+		if i+3 >= len(s) {
+			return "", fmt.Errorf("truncated escape sequence")
+		}
+
+		esc := s[i+1 : i+4]
+		switch esc {
+		case "040":
+			b.WriteByte(' ')
+		case "011":
+			b.WriteByte('\t')
+		case "012":
+			b.WriteByte('\n')
+		case "134":
+			b.WriteByte('\\')
+		default:
+			return "", fmt.Errorf("unsupported escape sequence \\%s", esc)
+		}
+
+		i += 3
+	}
+
+	return b.String(), nil
+}
diff --git a/clitools/pkg/node/uboot/ab.go b/clitools/pkg/node/uboot/ab.go
index cde8680..b610dc1 100644
--- a/clitools/pkg/node/uboot/ab.go
+++ b/clitools/pkg/node/uboot/ab.go
@@ -2,9 +2,14 @@ package uboot
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
+	"strings"
 
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+	"example.com/monok8s/pkg/controller/osimage"
 	"example.com/monok8s/pkg/node"
 )
 
@@ -15,13 +20,67 @@ func ConfigureABBoot(ctx context.Context, nctx *node.NodeContext) error {
 		return fmt.Errorf("get current executable path: %w", err)
 	}
 
+	doWrite := false
 	writer := NewFWEnvWriter(HostFWEnvCfgPath, exePath)
 
-	// TODO: configurable from cluster.env
-	return writer.EnsureBootEnv(ctx, BootEnvConfig{
-		BootSource: "usb",
-		BootPart:   "A",
-	})
+	bootPart, err := writer.GetEnv(ctx, "boot_part")
+	if err != nil {
+		if errors.Is(err, ErrEnvNotFound) {
+
+			state, err := osimage.ReadBootState()
+			if err != nil {
+				return fmt.Errorf("read boot state: %w", err)
+			}
+
+			bootPart := state["BOOT_PART"]
+			if bootPart == "" {
+				return fmt.Errorf("BOOT_PART missing")
+			}
+
+			doWrite = true
+		} else {
+			return fmt.Errorf("get boot_part: %w", err)
+		}
+	}
+
+	bootSource, err := writer.GetEnv(ctx, "boot_source")
+	if err != nil {
+		if errors.Is(err, ErrEnvNotFound) {
+			target, err := os.Readlink(monov1alpha1.AltPartDeviceLink)
+			if err != nil {
+				return fmt.Errorf("readlink %q: %w", monov1alpha1.AltPartDeviceLink, err)
+			}
+
+			if !filepath.IsAbs(target) {
+				target = filepath.Join(filepath.Dir(monov1alpha1.AltPartDeviceLink), target)
+			}
+
+			resolved, err := filepath.EvalSymlinks(target)
+			if err != nil {
+				return fmt.Errorf("resolve %q: %w", target, err)
+			}
+
+			base := filepath.Base(resolved)
+
+			if strings.HasPrefix(base, "mmcblk") {
+				bootSource = "emmc"
+			} else {
+				bootSource = "usb"
+			}
+			doWrite = true
+		} else {
+			return fmt.Errorf("get boot_source: %w", err)
+		}
+	}
+
+	if doWrite {
+		return writer.EnsureBootEnv(ctx, BootEnvConfig{
+			BootSource: bootSource,
+			BootPart:   bootPart,
+		})
+	}
+
+	return nil
 }
 
 // This is called by the agent controller/osupgrade/handler.go
diff --git a/clitools/pkg/node/uboot/env_writer.go b/clitools/pkg/node/uboot/env_writer.go
index 8cb9e32..fef4c24 100644
--- a/clitools/pkg/node/uboot/env_writer.go
+++ b/clitools/pkg/node/uboot/env_writer.go
@@ -2,6 +2,7 @@ package uboot
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"strings"
 	"time"
@@ -11,6 +12,8 @@ import (
 	"example.com/monok8s/pkg/system"
 )
 
+var ErrEnvNotFound = errors.New("fw env not found")
+
 func NewFWEnvWriter(configPath string, ctlPath string) *FWEnvWriter {
 	return &FWEnvWriter{
 		Runner: system.NewRunner(system.RunnerConfig{
@@ -38,8 +41,12 @@ func (w *FWEnvWriter) GetEnv(ctx context.Context, key string) (string, error) {
 	res, err := w.Runner.RunWithOptions(ctx, w.CtlPath, args, system.RunOptions{Quiet: true})
 	if err != nil {
 		if res != nil {
-			return "", fmt.Errorf("fw-printenv %q: %w (stdout=%q stderr=%q)",
-				key, err, strings.TrimSpace(res.Stdout), strings.TrimSpace(res.Stderr))
+			if strings.Contains(res.Stderr, "not defined") {
+				return "", ErrEnvNotFound
+			} else {
+				return "", fmt.Errorf("fw-printenv %q: %w (stdout=%q stderr=%q)",
+					key, err, strings.TrimSpace(res.Stdout), strings.TrimSpace(res.Stderr))
+			}
 		}
 		return "", fmt.Errorf("fw-printenv %q: %w", key, err)
 	}
diff --git a/docs/flashing-usb.md b/docs/flashing-usb.md
index 1b1e782..9d62b6c 100644
--- a/docs/flashing-usb.md
+++ b/docs/flashing-usb.md
@@ -23,8 +23,24 @@ bootm 0x80000000
 flash-emmc.sh
 ```
 
-4. If it boots, create the A/B deployment scheme
- - (WORK IN PROGRESS)
+6. Reboot into uboot, boot using the following commands
+```
+setenv boot_source emmc
+setenv boot_part A
+
+setenv rootpart 2;
+
+setenv bootdev 0:${rootpart};
+setenv rootdev emmc:2;
+
+setenv kernel_addr_r 0xa0000000;
+
+setenv bootargs "${bootargs_console} root=${rootdev} bootpart=${boot_part} rw rootwait rootfstype=ext4";
+ext4load mmc ${bootdev} ${kernel_addr_r} /boot/kernel.itb && bootm ${kernel_addr_r};'
+```
+
+7. tail /var/log/monok8s/bootstrap.log
+
 
 ## Flashing into USB
 On MacOS
diff --git a/initramfs/rootfs-extra/init b/initramfs/rootfs-extra/init
index 1483568..c3c9275 100755
--- a/initramfs/rootfs-extra/init
+++ b/initramfs/rootfs-extra/init
@@ -221,7 +221,7 @@ log "Booting kernel took $(cut -d' ' -f1 /proc/uptime) seconds."
 
 . /etc/build-info || panic "failed to source /etc/build-info"
 
-wait_for_partnames 30 rootfsA rootfsB data || panic "failed to wait for fs"
+wait_for_partnames 5 rootfsA rootfsB data || panic "failed to wait for fs"
 
 ROOT_CMD="$(get_cmdline_arg root || true)"
 BOOT_PART="$(get_cmdline_arg bootpart || true)"
@@ -283,6 +283,15 @@ if [ -n "$ALT_PART" ]; then
     ln -sf "$ALT_PART" /dev/mksaltpart
 fi
 
+mkdir -p /run/monok8s
+cat > /run/monok8s/boot-state.env <<EOF
+BOOT_PART=$BOOT_PART
+ROOT_DEV=$ROOT_DEV
+DATA_DEV=$DATA_DEV
+EOF
+
+mkdir -p /run/altrootfs
+
 mount_or_panic --move /dev /newroot/dev
 mount_or_panic --move /proc /newroot/proc
 mount_or_panic --move /sys /newroot/sys