penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 9cb593ffc0f79704150cbd6197ac71c85d8b56b458567d65488470d373c44b63

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-04-03T00:57:27Z
subjectOSUpgrade: Version Planning
commit 9cb593ffc0f79704150cbd6197ac71c85d8b56b458567d65488470d373c44b63
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-04-03T00:57:27Z

    OSUpgrade: Version Planning
---
 clitools/makefile                                  |   2 +-
 .../pkg/apis/monok8s/v1alpha1/groupversion_info.go |   1 +
 clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go    |  35 ++-
 clitools/pkg/catalog/resolver.go                   | 113 +++++++
 clitools/pkg/catalog/types.go                      |  13 +
 clitools/pkg/cmd/agent/agent.go                    | 183 ++++++++++--
 clitools/pkg/controller/osupgrade/handler.go       |  80 +++++
 clitools/pkg/controller/osupgrade/planner.go       | 327 +++++++++++++++++++++
 clitools/pkg/controller/osupgrade/progress.go      | 265 +++++++++++++++++
 clitools/pkg/templates/templates.go                |   4 +-
 10 files changed, 985 insertions(+), 38 deletions(-)

diff --git a/clitools/makefile b/clitools/makefile
index 96e8870..10e2719 100644
--- a/clitools/makefile
+++ b/clitools/makefile
@@ -2,7 +2,7 @@
 VERSION ?= dev
 
 # Target kube version
-KUBE_VERSION ?= v1.35.1
+KUBE_VERSION ?= v1.34.1
 
 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 77b6cbe..5966af6 100644
--- a/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go
+++ b/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go
@@ -17,6 +17,7 @@ var (
 	Label           = "monok8s.io/label"
 	Annotation      = "monok8s.io/annotation"
 	ControlAgentKey = "monok8s.io/control-agent"
+	CatalogURL      = "https://example.com/monok8s.io/v1alpha1/catalog.yaml"
 )
 
 var (
diff --git a/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go b/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go
index a86c88c..9c5a248 100644
--- a/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go
+++ b/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go
@@ -56,12 +56,17 @@ type OSUpgradeSpec struct {
 	// User request, can be "stable" or an explicit version like "v1.35.3".
 	DesiredVersion string `json:"desiredVersion,omitempty" yaml:"desiredVersion,omitempty"`
 
-	ImageURL string `json:"imageURL,omitempty" yaml:"imageURL,omitempty"`
-	Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"`
+	Catalog *VersionCatalogSource `json:"catalog,omitempty"`
 
 	NodeSelector *metav1.LabelSelector `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"`
 }
 
+type VersionCatalogSource struct {
+	URL       string `json:"url,omitempty"`
+	Inline    string `json:"inline,omitempty"`
+	ConfigMap string `json:"configMapRef,omitempty"`
+}
+
 type OSUpgradeStatus struct {
 	Phase              OSUpgradePhase     `json:"phase,omitempty" yaml:"phase,omitempty"`
 	ResolvedVersion    string             `json:"resolvedVersion,omitempty" yaml:"resolvedVersion,omitempty"`
@@ -115,16 +120,22 @@ type OSUpgradeSourceRef struct {
 }
 
 type OSUpgradeProgressStatus struct {
-	CurrentVersion    string                 `json:"currentVersion,omitempty" yaml:"currentVersion,omitempty"`
-	TargetVersion     string                 `json:"targetVersion,omitempty" yaml:"targetVersion,omitempty"`
-	Phase             OSUpgradeProgressPhase `json:"phase,omitempty" yaml:"phase,omitempty"`
-	StartedAt         *metav1.Time           `json:"startedAt,omitempty" yaml:"startedAt,omitempty"`
-	CompletedAt       *metav1.Time           `json:"completedAt,omitempty" yaml:"completedAt,omitempty"`
-	LastUpdatedAt     *metav1.Time           `json:"lastUpdatedAt,omitempty" yaml:"lastUpdatedAt,omitempty"`
-	RetryCount        int32                  `json:"retryCount,omitempty" yaml:"retryCount,omitempty"`
-	InactivePartition string                 `json:"inactivePartition,omitempty" yaml:"inactivePartition,omitempty"`
-	FailureReason     string                 `json:"failureReason,omitempty" yaml:"failureReason,omitempty"`
-	Message           string                 `json:"message,omitempty" yaml:"message,omitempty"`
+	CurrentVersion string                 `json:"currentVersion,omitempty" yaml:"currentVersion,omitempty"`
+	TargetVersion  string                 `json:"targetVersion,omitempty" yaml:"targetVersion,omitempty"`
+	Phase          OSUpgradeProgressPhase `json:"phase,omitempty" yaml:"phase,omitempty"`
+
+	StartedAt         *metav1.Time `json:"startedAt,omitempty" yaml:"startedAt,omitempty"`
+	CompletedAt       *metav1.Time `json:"completedAt,omitempty" yaml:"completedAt,omitempty"`
+	LastUpdatedAt     *metav1.Time `json:"lastUpdatedAt,omitempty" yaml:"lastUpdatedAt,omitempty"`
+	RetryCount        int32        `json:"retryCount,omitempty" yaml:"retryCount,omitempty"`
+	InactivePartition string       `json:"inactivePartition,omitempty" yaml:"inactivePartition,omitempty"`
+	FailureReason     string       `json:"failureReason,omitempty" yaml:"failureReason,omitempty"`
+	Message           string       `json:"message,omitempty" yaml:"message,omitempty"`
+
+	PlannedPath []string `json:"plannedPath,omitempty"`
+	CurrentStep int32    `json:"currentStep,omitempty"`
+	CurrentFrom string   `json:"currentFrom,omitempty"`
+	CurrentTo   string   `json:"currentTo,omitempty"`
 }
 
 func (in *OSUpgrade) DeepCopyObject() runtime.Object {
diff --git a/clitools/pkg/catalog/resolver.go b/clitools/pkg/catalog/resolver.go
new file mode 100644
index 0000000..f9e347d
--- /dev/null
+++ b/clitools/pkg/catalog/resolver.go
@@ -0,0 +1,113 @@
+package catalog
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+
+	"sigs.k8s.io/yaml"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+)
+
+const (
+	DefaultCachePath = "/var/lib/monok8s/catalog.yaml"
+)
+
+// ResolveCatalog resolves catalog using priority:
+// Inline > ConfigMap > URL > cached
+func ResolveCatalog(
+	ctx context.Context,
+	kubeClient kubernetes.Interface,
+	namespace string,
+	src *monov1alpha1.VersionCatalogSource,
+) (*VersionCatalog, error) {
+
+	// 1. Inline
+	if src != nil && src.Inline != "" {
+		return parseCatalog([]byte(src.Inline))
+	}
+
+	// 2. ConfigMap
+	if src != nil && src.ConfigMap != "" {
+		cm, err := kubeClient.CoreV1().ConfigMaps(namespace).Get(ctx, src.ConfigMap, metav1.GetOptions{})
+		if err != nil {
+			return nil, fmt.Errorf("get catalog configmap: %w", err)
+		}
+
+		data, ok := cm.Data["catalog.yaml"]
+		if !ok {
+			return nil, fmt.Errorf("configmap %s missing key catalog.yaml", src.ConfigMap)
+		}
+
+		return parseCatalog([]byte(data))
+	}
+
+	// 3. URL
+	if src != nil && src.URL != "" {
+		cat, err := fetchCatalog(src.URL)
+		if err == nil {
+			_ = os.WriteFile(DefaultCachePath, mustMarshal(cat), 0644)
+			return cat, nil
+		}
+
+		// fallback to cache
+		if cached, err2 := loadCached(); err2 == nil {
+			return cached, nil
+		}
+
+		return nil, fmt.Errorf("fetch catalog failed and no cache: %w", err)
+	}
+
+	// 4. cached fallback
+	if cached, err := loadCached(); err == nil {
+		return cached, nil
+	}
+
+	return nil, fmt.Errorf("no catalog source available")
+}
+
+func fetchCatalog(url string) (*VersionCatalog, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("http %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	return parseCatalog(body)
+}
+
+func loadCached() (*VersionCatalog, error) {
+	data, err := os.ReadFile(DefaultCachePath)
+	if err != nil {
+		return nil, err
+	}
+	return parseCatalog(data)
+}
+
+func parseCatalog(data []byte) (*VersionCatalog, error) {
+	var c VersionCatalog
+	if err := yaml.Unmarshal(data, &c); err != nil {
+		return nil, fmt.Errorf("parse catalog: %w", err)
+	}
+	return &c, nil
+}
+
+func mustMarshal(c *VersionCatalog) []byte {
+	b, _ := yaml.Marshal(c)
+	return b
+}
diff --git a/clitools/pkg/catalog/types.go b/clitools/pkg/catalog/types.go
new file mode 100644
index 0000000..e78f0a9
--- /dev/null
+++ b/clitools/pkg/catalog/types.go
@@ -0,0 +1,13 @@
+package catalog
+
+type VersionCatalog struct {
+	Stable  string         `json:"stable" yaml:"stable"`
+	Images  []CatalogImage `json:"images" yaml:"images"`
+	Blocked []string       `json:"blocked,omitempty" yaml:"blocked,omitempty"`
+}
+
+type CatalogImage struct {
+	Version  string `json:"version" yaml:"version"`
+	URL      string `json:"url" yaml:"url"`
+	Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"`
+}
diff --git a/clitools/pkg/cmd/agent/agent.go b/clitools/pkg/cmd/agent/agent.go
index ab8c736..edd050c 100644
--- a/clitools/pkg/cmd/agent/agent.go
+++ b/clitools/pkg/cmd/agent/agent.go
@@ -7,63 +7,198 @@ import (
 
 	"github.com/spf13/cobra"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/klog/v2"
 
 	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
 	mkscmd "example.com/monok8s/pkg/cmd"
+	osupgradeController "example.com/monok8s/pkg/controller/osupgrade"
 	"example.com/monok8s/pkg/kube"
 	"example.com/monok8s/pkg/templates"
 )
 
+const defaultPollInterval = 15 * time.Second
+
+var runtimeDefaultUnstructuredConverter = runtime.DefaultUnstructuredConverter
+
 func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command {
 	var namespace string
 	var envFile string
+	var pollInterval time.Duration
 
 	cmd := &cobra.Command{
 		Use:   "agent --env-file path",
-		Short: "Watch OSUpgrade resources and do nothing for now",
+		Short: "Watch OSUpgrade resources and process matching upgrades for this node",
 		RunE: func(cmd *cobra.Command, _ []string) error {
-
-			var cfg *monov1alpha1.MonoKSConfig // or value, depending on your API
+			if envFile == "" {
+				return fmt.Errorf("--env-file is required")
+			}
 
 			if err := mkscmd.LoadEnvFile(envFile); err != nil {
 				return fmt.Errorf("load env file %q: %w", envFile, err)
 			}
+
 			vals := templates.LoadTemplateValuesFromEnv()
 			rendered := templates.DefaultMonoKSConfig(vals)
-			cfg = &rendered
+			cfg := &rendered
+
+			if cfg.Spec.NodeName == "" {
+				return fmt.Errorf("node name is empty in rendered config")
+			}
 
-			klog.InfoS("starting agent", "node", cfg.Spec.NodeName, "envFile", envFile)
+			klog.InfoS("starting agent",
+				"node", cfg.Spec.NodeName,
+				"namespace", namespace,
+				"envFile", envFile,
+				"pollInterval", pollInterval,
+			)
 
 			clients, err := kube.NewClients(flags)
 			if err != nil {
-				return err
+				return fmt.Errorf("create kube clients: %w", err)
 			}
-			gvr := schema.GroupVersionResource{Group: monov1alpha1.Group, Version: monov1alpha1.Version, Resource: "osupgrades"}
+
 			ctx := cmd.Context()
-			for {
-				list, err := clients.Dynamic.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
-				if err != nil {
-					return err
-				}
-				klog.InfoS("agent tick", "namespace", namespace, "items", len(list.Items))
-				for _, item := range list.Items {
-					klog.InfoS("observed osupgrade", "name", item.GetName(), "resourceVersion", item.GetResourceVersion())
-				}
-				select {
-				case <-ctx.Done():
-					return ctx.Err()
-				case <-time.After(15 * time.Second):
-				}
-			}
+			return runPollLoop(ctx, clients, namespace, cfg.Spec.NodeName, pollInterval)
 		},
 	}
+
 	cmd.Flags().StringVar(&namespace, "namespace", "kube-system", "namespace to watch")
 	cmd.Flags().StringVar(&envFile, "env-file", "", "path to env file containing MKS_* variables")
+	cmd.Flags().DurationVar(&pollInterval, "poll-interval", defaultPollInterval, "poll interval for OSUpgrade resources")
+
 	return cmd
 }
 
-var _ = context.Background
-var _ = fmt.Sprintf
+func runPollLoop(ctx context.Context, clients *kube.Clients, namespace, nodeName string, interval time.Duration) error {
+	gvr := schema.GroupVersionResource{
+		Group:    monov1alpha1.Group,
+		Version:  monov1alpha1.Version,
+		Resource: "osupgrades",
+	}
+
+	ticker := time.NewTicker(interval)
+	defer ticker.Stop()
+
+	for {
+		if err := pollOnce(ctx, clients, gvr, namespace, nodeName); err != nil {
+			klog.ErrorS(err, "poll failed", "namespace", namespace, "node", nodeName)
+		}
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-ticker.C:
+		}
+	}
+}
+
+func pollOnce(
+	ctx context.Context,
+	clients *kube.Clients,
+	gvr schema.GroupVersionResource,
+	namespace string,
+	nodeName string,
+) error {
+	list, err := clients.Dynamic.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
+	if err != nil {
+		return fmt.Errorf("list osupgrades: %w", err)
+	}
+
+	klog.InfoS("agent tick", "namespace", namespace, "items", len(list.Items), "node", nodeName)
+
+	nodeLabels := labels.Set{
+		"kubernetes.io/hostname":   nodeName,
+		"monok8s.io/node-name":     nodeName,
+		"monok8s.io/control-agent": "true",
+	}
+
+	for i := range list.Items {
+		item := &list.Items[i]
+
+		osu, err := decodeOSUpgrade(item)
+		if err != nil {
+			klog.ErrorS(err, "failed to decode osupgrade",
+				"name", item.GetName(),
+				"resourceVersion", item.GetResourceVersion(),
+			)
+			continue
+		}
+
+		if !matchesNode(osu, nodeName, nodeLabels) {
+			klog.V(2).InfoS("skipping osupgrade; not targeted to this node",
+				"name", osu.Name,
+				"node", nodeName,
+			)
+			continue
+		}
+
+		klog.InfoS("matched osupgrade",
+			"name", osu.Name,
+			"node", nodeName,
+			"desiredVersion", osu.Spec.DesiredVersion,
+			"phase", statusPhase(osu.Status),
+			"resourceVersion", osu.ResourceVersion,
+		)
+
+		if err := osupgradeController.HandleOSUpgrade(ctx, clients, namespace, nodeName, osu); err != nil {
+			klog.ErrorS(err, "failed to handle osupgrade",
+				"name", osu.Name,
+				"node", nodeName,
+			)
+			continue
+		}
+	}
+
+	return nil
+}
+
+func decodeOSUpgrade(item *unstructured.Unstructured) (*monov1alpha1.OSUpgrade, error) {
+	var osu monov1alpha1.OSUpgrade
+	if err := runtimeDefaultUnstructuredConverter.FromUnstructured(item.Object, &osu); err != nil {
+		return nil, fmt.Errorf("convert unstructured to OSUpgrade: %w", err)
+	}
+	return &osu, nil
+}
+
+func matchesNode(osu *monov1alpha1.OSUpgrade, nodeName string, nodeLabels labels.Set) bool {
+	if osu == nil {
+		return false
+	}
+
+	sel := osu.Spec.NodeSelector
+	if sel == nil {
+		// No selector means "match all nodes".
+		return true
+	}
+
+	selector, err := metav1.LabelSelectorAsSelector(sel)
+	if err != nil {
+		klog.ErrorS(err, "invalid node selector on osupgrade", "name", osu.Name)
+		return false
+	}
+
+	if selector.Empty() {
+		return true
+	}
+
+	if selector.Matches(nodeLabels) {
+		return true
+	}
+
+	// Cheap fallback in case you temporarily target by explicit hostname-ish labels only.
+	return nodeName != "" && selector.Matches(labels.Set{
+		"kubernetes.io/hostname": nodeName,
+	})
+}
+
+func statusPhase(st *monov1alpha1.OSUpgradeStatus) string {
+	if st == nil {
+		return ""
+	}
+	return string(st.Phase)
+}
diff --git a/clitools/pkg/controller/osupgrade/handler.go b/clitools/pkg/controller/osupgrade/handler.go
new file mode 100644
index 0000000..85378c5
--- /dev/null
+++ b/clitools/pkg/controller/osupgrade/handler.go
@@ -0,0 +1,80 @@
+package osupgrade
+
+import (
+	"context"
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/klog/v2"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+	"example.com/monok8s/pkg/buildinfo"
+	"example.com/monok8s/pkg/catalog"
+	"example.com/monok8s/pkg/kube"
+)
+
+func HandleOSUpgrade(
+	ctx context.Context,
+	clients *kube.Clients,
+	namespace string,
+	nodeName string,
+	osu *monov1alpha1.OSUpgrade,
+) error {
+	osup, err := ensureProgressHeartbeat(ctx, clients, namespace, nodeName, osu)
+	if err != nil {
+		return err
+	}
+
+	klog.InfoS("handling osupgrade",
+		"name", osu.Name,
+		"node", nodeName,
+		"desiredVersion", osu.Spec.DesiredVersion,
+	)
+
+	kata, err := catalog.ResolveCatalog(ctx, clients.Kubernetes, namespace, osu.Spec.Catalog)
+	if err != nil {
+		return failProgress(ctx, clients, osup, "resolve catalog", err)
+	}
+
+	plan, err := PlanUpgrade(buildinfo.KubeVersion, osu, kata)
+	if err != nil {
+		return failProgress(ctx, clients, osup, "plan upgrade", err)
+	}
+
+	if len(plan.Path) == 0 {
+		osup.Status.CurrentVersion = buildinfo.KubeVersion
+		osup.Status.TargetVersion = buildinfo.KubeVersion
+		if err := markProgressCompleted(ctx, clients, osup, "already at target version"); err != nil {
+			return err
+		}
+		klog.InfoS("osupgrade already satisfied",
+			"name", osu.Name,
+			"node", nodeName,
+			"target", plan.ResolvedTarget,
+		)
+		return nil
+	}
+
+	first := plan.Path[0]
+
+	osup.Status.TargetVersion = plan.ResolvedTarget
+	osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseDownloading
+	osup.Status.Message = fmt.Sprintf("downloading image: %s", first.URL)
+	now := metav1.Now()
+	osup.Status.LastUpdatedAt = &now
+
+	if _, err := updateProgressStatus(ctx, clients, osup_gvr, osup); err != nil {
+		return fmt.Errorf("update progress status: %w", err)
+	}
+
+	klog.InfoS("planned osupgrade",
+		"name", osu.Name,
+		"node", nodeName,
+		"resolvedTarget", plan.ResolvedTarget,
+		"steps", len(plan.Path),
+		"firstVersion", first.Version,
+		"firstURL", first.URL,
+	)
+
+	return nil
+}
diff --git a/clitools/pkg/controller/osupgrade/planner.go b/clitools/pkg/controller/osupgrade/planner.go
new file mode 100644
index 0000000..29b52a4
--- /dev/null
+++ b/clitools/pkg/controller/osupgrade/planner.go
@@ -0,0 +1,327 @@
+package osupgrade
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+	"example.com/monok8s/pkg/catalog"
+)
+
+type Version struct {
+	Major int
+	Minor int
+	Patch int
+	Raw   string
+}
+
+func ParseVersion(s string) (Version, error) {
+	raw := strings.TrimSpace(s)
+	if raw == "" {
+		return Version{}, fmt.Errorf("empty version")
+	}
+
+	raw = strings.TrimPrefix(raw, "v")
+	parts := strings.Split(raw, ".")
+	if len(parts) != 3 {
+		return Version{}, fmt.Errorf("invalid version %q: expected vMAJOR.MINOR.PATCH", s)
+	}
+
+	maj, err := strconv.Atoi(parts[0])
+	if err != nil {
+		return Version{}, fmt.Errorf("parse major from %q: %w", s, err)
+	}
+	min, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return Version{}, fmt.Errorf("parse minor from %q: %w", s, err)
+	}
+	patch, err := strconv.Atoi(parts[2])
+	if err != nil {
+		return Version{}, fmt.Errorf("parse patch from %q: %w", s, err)
+	}
+
+	return Version{
+		Major: maj,
+		Minor: min,
+		Patch: patch,
+		Raw:   fmt.Sprintf("v%d.%d.%d", maj, min, patch),
+	}, nil
+}
+
+func (v Version) String() string {
+	return v.Raw
+}
+
+func (v Version) Compare(o Version) int {
+	if v.Major != o.Major {
+		if v.Major < o.Major {
+			return -1
+		}
+		return 1
+	}
+	if v.Minor != o.Minor {
+		if v.Minor < o.Minor {
+			return -1
+		}
+		return 1
+	}
+	if v.Patch != o.Patch {
+		if v.Patch < o.Patch {
+			return -1
+		}
+		return 1
+	}
+	return 0
+}
+
+func (v Version) SameMinor(o Version) bool {
+	return v.Major == o.Major && v.Minor == o.Minor
+}
+
+type Plan struct {
+	ResolvedTarget string
+	Path           []catalog.CatalogImage
+}
+
+func PlanUpgrade(
+	current string,
+	osu *monov1alpha1.OSUpgrade,
+	cat *catalog.VersionCatalog,
+) (*Plan, error) {
+	target, err := resolveTarget(osu.Spec.DesiredVersion, cat)
+	if err != nil {
+		return nil, err
+	}
+
+	if isBlocked(target, cat.Blocked) {
+		return nil, fmt.Errorf("target %s is blocked", target)
+	}
+
+	imagesByVersion := make(map[string]catalog.CatalogImage, len(cat.Images))
+	installable := make([]string, 0, len(cat.Images))
+
+	for _, img := range cat.Images {
+		if img.Version == "" {
+			continue
+		}
+		if isBlocked(img.Version, cat.Blocked) {
+			continue
+		}
+		if _, exists := imagesByVersion[img.Version]; exists {
+			return nil, fmt.Errorf("duplicate image entry for version %s", img.Version)
+		}
+		imagesByVersion[img.Version] = img
+		installable = append(installable, img.Version)
+	}
+
+	versionPath, err := calculatePath(current, target, installable)
+	if err != nil {
+		return nil, err
+	}
+
+	path := make([]catalog.CatalogImage, 0, len(versionPath))
+	for _, v := range versionPath {
+		img, ok := imagesByVersion[v]
+		if !ok {
+			return nil, fmt.Errorf("internal error: no image for planned version %s", v)
+		}
+		path = append(path, img)
+	}
+
+	return &Plan{
+		ResolvedTarget: target,
+		Path:           path,
+	}, nil
+}
+
+func installableVersions(cat *catalog.VersionCatalog) []string {
+	out := make([]string, 0, len(cat.Images))
+	for _, img := range cat.Images {
+		if img.Version == "" {
+			continue
+		}
+		if isBlocked(img.Version, cat.Blocked) {
+			continue
+		}
+		out = append(out, img.Version)
+	}
+	return out
+}
+
+func resolveTarget(desired string, cat *catalog.VersionCatalog) (string, error) {
+	if desired == "stable" {
+		if cat.Stable == "" {
+			return "", fmt.Errorf("catalog missing stable")
+		}
+		return cat.Stable, nil
+	}
+
+	for _, img := range cat.Images {
+		if img.Version == desired {
+			return desired, nil
+		}
+	}
+
+	return "", fmt.Errorf("desired version %s not in catalog", desired)
+}
+
+func calculatePath(current, target string, available []string) ([]string, error) {
+	cur, err := ParseVersion(current)
+	if err != nil {
+		return nil, fmt.Errorf("parse current version: %w", err)
+	}
+
+	tgt, err := ParseVersion(target)
+	if err != nil {
+		return nil, fmt.Errorf("parse target version: %w", err)
+	}
+
+	if cur.Compare(tgt) == 0 {
+		return nil, nil
+	}
+
+	if cur.Compare(tgt) > 0 {
+		return nil, fmt.Errorf("downgrade not supported: current=%s target=%s", cur, tgt)
+	}
+
+	if cur.Major != tgt.Major {
+		return nil, fmt.Errorf("cross-major upgrade not supported: %s -> %s", cur, tgt)
+	}
+
+	versions, err := parseAndSortVersions(available)
+	if err != nil {
+		return nil, err
+	}
+
+	if !containsVersion(versions, tgt) {
+		return nil, fmt.Errorf("target version %s not found in available versions", tgt)
+	}
+
+	var path []Version
+	seen := map[string]struct{}{}
+
+	add := func(v Version) {
+		if v.Compare(cur) <= 0 {
+			return
+		}
+		if _, ok := seen[v.String()]; ok {
+			return
+		}
+		seen[v.String()] = struct{}{}
+		path = append(path, v)
+	}
+
+	// Same minor: jump directly to target patch.
+	if cur.SameMinor(tgt) {
+		add(tgt)
+		return versionsToStrings(path), nil
+	}
+
+	// Step 1: finish current minor by moving to the latest patch available there.
+	if latestCurMinor, ok := latestPatchInMinor(versions, cur.Major, cur.Minor, cur); ok {
+		add(latestCurMinor)
+	}
+
+	// Step 2: walk each intermediate minor using the lowest available patch in that minor.
+	for minor := cur.Minor + 1; minor < tgt.Minor; minor++ {
+		bridge, ok := lowestPatchInMinor(versions, cur.Major, minor)
+		if !ok {
+			return nil, fmt.Errorf("no available bridge version for v%d.%d.x", cur.Major, minor)
+		}
+		add(bridge)
+	}
+
+	// Step 3: final target.
+	add(tgt)
+
+	return versionsToStrings(path), nil
+}
+
+func parseAndSortVersions(raw []string) ([]Version, error) {
+	out := make([]Version, 0, len(raw))
+	seen := map[string]struct{}{}
+
+	for _, s := range raw {
+		v, err := ParseVersion(s)
+		if err != nil {
+			return nil, fmt.Errorf("parse catalog version %q: %w", s, err)
+		}
+		if _, ok := seen[v.String()]; ok {
+			continue
+		}
+		seen[v.String()] = struct{}{}
+		out = append(out, v)
+	}
+
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].Compare(out[j]) < 0
+	})
+
+	return out, nil
+}
+
+func containsRawVersion(versions []string, want string) bool {
+	for _, v := range versions {
+		if strings.TrimSpace(v) == strings.TrimSpace(want) {
+			return true
+		}
+	}
+	return false
+}
+
+func containsVersion(versions []Version, want Version) bool {
+	for _, v := range versions {
+		if v.Compare(want) == 0 {
+			return true
+		}
+	}
+	return false
+}
+
+func isBlocked(version string, blocked []string) bool {
+	for _, v := range blocked {
+		if strings.TrimSpace(v) == strings.TrimSpace(version) {
+			return true
+		}
+	}
+	return false
+}
+
+func latestPatchInMinor(versions []Version, major, minor int, gt Version) (Version, bool) {
+	var found Version
+	ok := false
+
+	for _, v := range versions {
+		if v.Major != major || v.Minor != minor {
+			continue
+		}
+		if v.Compare(gt) <= 0 {
+			continue
+		}
+		if !ok || found.Compare(v) < 0 {
+			found = v
+			ok = true
+		}
+	}
+
+	return found, ok
+}
+
+func lowestPatchInMinor(versions []Version, major, minor int) (Version, bool) {
+	for _, v := range versions {
+		if v.Major == major && v.Minor == minor {
+			return v, true
+		}
+	}
+	return Version{}, false
+}
+
+func versionsToStrings(vs []Version) []string {
+	out := make([]string, 0, len(vs))
+	for _, v := range vs {
+		out = append(out, v.String())
+	}
+	return out
+}
diff --git a/clitools/pkg/controller/osupgrade/progress.go b/clitools/pkg/controller/osupgrade/progress.go
new file mode 100644
index 0000000..93f4588
--- /dev/null
+++ b/clitools/pkg/controller/osupgrade/progress.go
@@ -0,0 +1,265 @@
+package osupgrade
+
+import (
+	"context"
+	"fmt"
+
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/klog/v2"
+
+	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
+	"example.com/monok8s/pkg/buildinfo"
+	"example.com/monok8s/pkg/kube"
+)
+
+var (
+	unstructuredConverter = runtime.DefaultUnstructuredConverter
+
+	osup_gvr = schema.GroupVersionResource{
+		Group:    monov1alpha1.Group,
+		Version:  monov1alpha1.Version,
+		Resource: "osupgradeprogresses",
+	}
+)
+
+func ensureProgressHeartbeat(
+	ctx context.Context,
+	clients *kube.Clients,
+	namespace string,
+	nodeName string,
+	osu *monov1alpha1.OSUpgrade,
+) (*monov1alpha1.OSUpgradeProgress, error) {
+
+	name := fmt.Sprintf("%s-%s", osu.Name, nodeName)
+	now := metav1.Now()
+
+	currentVersion := buildinfo.KubeVersion
+	targetVersion := ""
+
+	if osu.Status != nil {
+		targetVersion = osu.Status.ResolvedVersion
+	}
+
+	progress := &monov1alpha1.OSUpgradeProgress{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: monov1alpha1.APIVersion,
+			Kind:       "OSUpgradeProgress",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: monov1alpha1.OSUpgradeProgressSpec{
+			NodeName: nodeName,
+			SourceRef: monov1alpha1.OSUpgradeSourceRef{
+				Name: osu.Name,
+			},
+		},
+		Status: &monov1alpha1.OSUpgradeProgressStatus{
+			CurrentVersion: currentVersion,
+			TargetVersion:  targetVersion,
+			Phase:          monov1alpha1.OSUpgradeProgressPhasePending,
+			LastUpdatedAt:  &now,
+			Message:        "acknowledged",
+		},
+	}
+
+	created, err := createProgress(ctx, clients, osup_gvr, progress)
+	if err == nil {
+		klog.InfoS("created osupgradeprogress", "name", created.Name, "namespace", created.Namespace)
+		return created, nil
+	}
+	if !apierrors.IsAlreadyExists(err) {
+		return nil, fmt.Errorf("create OSUpgradeProgress %s/%s: %w", namespace, name, err)
+	}
+
+	existing, err := getProgress(ctx, clients, osup_gvr, namespace, name)
+	if err != nil {
+		return nil, fmt.Errorf("get existing OSUpgradeProgress %s/%s: %w", namespace, name, err)
+	}
+
+	// Spec should remain aligned with the source and node.
+	existing.Spec.NodeName = nodeName
+	existing.Spec.SourceRef.Name = osu.Name
+
+	if existing, err = updateProgressSpec(ctx, clients, osup_gvr, existing); err != nil {
+		return nil, fmt.Errorf("update OSUpgradeProgress spec %s/%s: %w", namespace, name, err)
+	}
+
+	if existing.Status == nil {
+		existing.Status = &monov1alpha1.OSUpgradeProgressStatus{}
+	}
+
+	existing.Status.CurrentVersion = currentVersion
+	existing.Status.TargetVersion = targetVersion
+	existing.Status.LastUpdatedAt = &now
+
+	// Only set phase/message if they are still empty, so later real state machine
+	// updates are not clobbered by the heartbeat.
+	if existing.Status.Phase == "" {
+		existing.Status.Phase = monov1alpha1.OSUpgradeProgressPhasePending
+	}
+
+	if existing, err = updateProgressStatus(ctx, clients, osup_gvr, existing); err != nil {
+		return nil, fmt.Errorf("update OSUpgradeProgress status %s/%s: %w", namespace, name, err)
+	}
+
+	klog.InfoS("updated osupgradeprogress", "name", existing.Name, "namespace", existing.Namespace)
+	return existing, nil
+}
+
+func createProgress(
+	ctx context.Context,
+	clients *kube.Clients,
+	gvr schema.GroupVersionResource,
+	progress *monov1alpha1.OSUpgradeProgress,
+) (*monov1alpha1.OSUpgradeProgress, error) {
+	obj, err := toUnstructured(progress)
+	if err != nil {
+		return nil, err
+	}
+
+	created, err := clients.Dynamic.
+		Resource(gvr).
+		Namespace(progress.Namespace).
+		Create(ctx, obj, metav1.CreateOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	return fromUnstructuredProgress(created)
+}
+
+func getProgress(
+	ctx context.Context,
+	clients *kube.Clients,
+	gvr schema.GroupVersionResource,
+	namespace, name string,
+) (*monov1alpha1.OSUpgradeProgress, error) {
+	got, err := clients.Dynamic.
+		Resource(gvr).
+		Namespace(namespace).
+		Get(ctx, name, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	return fromUnstructuredProgress(got)
+}
+
+func updateProgressSpec(
+	ctx context.Context,
+	clients *kube.Clients,
+	gvr schema.GroupVersionResource,
+	progress *monov1alpha1.OSUpgradeProgress,
+) (*monov1alpha1.OSUpgradeProgress, error) {
+	obj, err := toUnstructured(progress)
+	if err != nil {
+		return nil, err
+	}
+
+	updated, err := clients.Dynamic.
+		Resource(gvr).
+		Namespace(progress.Namespace).
+		Update(ctx, obj, metav1.UpdateOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	return fromUnstructuredProgress(updated)
+}
+
+func updateProgressStatus(
+	ctx context.Context,
+	clients *kube.Clients,
+	gvr schema.GroupVersionResource,
+	progress *monov1alpha1.OSUpgradeProgress,
+) (*monov1alpha1.OSUpgradeProgress, error) {
+	obj, err := toUnstructured(progress)
+	if err != nil {
+		return nil, err
+	}
+
+	updated, err := clients.Dynamic.
+		Resource(gvr).
+		Namespace(progress.Namespace).
+		UpdateStatus(ctx, obj, metav1.UpdateOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	return fromUnstructuredProgress(updated)
+}
+
+func failProgress(
+	ctx context.Context,
+	clients *kube.Clients,
+	osup *monov1alpha1.OSUpgradeProgress,
+	action string,
+	cause error,
+) error {
+	now := metav1.Now()
+
+	if osup.Status == nil {
+		osup.Status = &monov1alpha1.OSUpgradeProgressStatus{}
+	}
+
+	osup.Status.LastUpdatedAt = &now
+	osup.Status.Message = fmt.Sprintf("%s: %v", action, cause)
+	osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseFailed
+
+	if _, err := updateProgressStatus(ctx, clients, osup_gvr, osup); err != nil {
+		klog.ErrorS(err, "failed to update osupgradeprogress status after error",
+			"action", action,
+			"name", osup.Name,
+			"namespace", osup.Namespace,
+		)
+	}
+
+	return fmt.Errorf("%s: %w", action, cause)
+}
+
+func markProgressCompleted(
+	ctx context.Context,
+	clients *kube.Clients,
+	osup *monov1alpha1.OSUpgradeProgress,
+	message string,
+) error {
+	now := metav1.Now()
+
+	if osup.Status == nil {
+		osup.Status = &monov1alpha1.OSUpgradeProgressStatus{}
+	}
+
+	osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseCompleted
+	osup.Status.Message = message
+	osup.Status.LastUpdatedAt = &now
+	osup.Status.CompletedAt = &now
+
+	_, err := updateProgressStatus(ctx, clients, osup_gvr, osup)
+	if err != nil {
+		return fmt.Errorf("mark progress completed: %w", err)
+	}
+
+	return nil
+}
+
+func toUnstructured(progress *monov1alpha1.OSUpgradeProgress) (*unstructured.Unstructured, error) {
+	m, err := unstructuredConverter.ToUnstructured(progress)
+	if err != nil {
+		return nil, fmt.Errorf("convert OSUpgradeProgress to unstructured: %w", err)
+	}
+	return &unstructured.Unstructured{Object: m}, nil
+}
+
+func fromUnstructuredProgress(obj *unstructured.Unstructured) (*monov1alpha1.OSUpgradeProgress, error) {
+	var progress monov1alpha1.OSUpgradeProgress
+	if err := unstructuredConverter.FromUnstructured(obj.Object, &progress); err != nil {
+		return nil, fmt.Errorf("convert unstructured to OSUpgradeProgress: %w", err)
+	}
+	return &progress, nil
+}
diff --git a/clitools/pkg/templates/templates.go b/clitools/pkg/templates/templates.go
index ac3878f..0436941 100644
--- a/clitools/pkg/templates/templates.go
+++ b/clitools/pkg/templates/templates.go
@@ -77,12 +77,14 @@ func DefaultOSUpgrade(v TemplateValues) monov1alpha1.OSUpgrade {
 		},
 		Spec: monov1alpha1.OSUpgradeSpec{
 			DesiredVersion: v.KubernetesVersion,
-			ImageURL:       "https://example.invalid/images/monok8s-v0.0.1.img.zst",
 			NodeSelector: &metav1.LabelSelector{
 				MatchLabels: map[string]string{
 					"kubernetes.io/hostname": firstNonEmpty(v.NodeName, v.Hostname),
 				},
 			},
+			Catalog: &monov1alpha1.VersionCatalogSource{
+				URL: monov1alpha1.CatalogURL,
+			},
 		},
 	}
 }