penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 4f490ab37e642b52c681edb18cd1b5933985247577e1555ee96e179470c61678

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-04-03T18:45:46Z
subjectWrites and verify image
commit 4f490ab37e642b52c681edb18cd1b5933985247577e1555ee96e179470c61678
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-04-03T18:45:46Z

    Writes and verify image
---
 clitools/go.mod                                  |   7 +-
 clitools/go.sum                                  |   2 +
 clitools/makefile                                |   6 +-
 clitools/pkg/catalog/checksums.go                |  30 ++++++
 clitools/pkg/catalog/resolver.go                 |  14 ++-
 clitools/pkg/catalog/types.go                    |   1 +
 clitools/pkg/cmd/agent/agent.go                  |   5 +-
 clitools/pkg/cmd/initcmd/init.go                 |   2 +-
 clitools/pkg/controller/osimage/apply.go         |  42 ++++++++
 clitools/pkg/controller/osimage/helpers.go       |  15 +++
 clitools/pkg/controller/osimage/progress.go      | 121 +++++++++++++++++++++++
 clitools/pkg/controller/osimage/stream.go        |  50 ++++++++++
 clitools/pkg/controller/osimage/types.go         |  29 ++++++
 clitools/pkg/controller/osimage/verify.go        | 104 +++++++++++++++++++
 clitools/pkg/controller/osimage/verify_safe.go   |  35 +++++++
 clitools/pkg/controller/osimage/verify_unsafe.go |   7 ++
 clitools/pkg/controller/osimage/write.go         |  82 +++++++++++++++
 clitools/pkg/controller/osupgrade/handler.go     |  68 +++++++++++--
 clitools/pkg/controller/osupgrade/progress.go    |   7 +-
 clitools/pkg/node/kubeadm.go                     |  34 -------
 20 files changed, 596 insertions(+), 65 deletions(-)

diff --git a/clitools/go.mod b/clitools/go.mod
index 389f4cf..aec328a 100644
--- a/clitools/go.mod
+++ b/clitools/go.mod
@@ -3,13 +3,17 @@ module example.com/monok8s
 go 1.24.0
 
 require (
+	github.com/klauspost/compress v1.18.5
 	github.com/spf13/cobra v1.9.1
+	golang.org/x/sys v0.31.0
 	gopkg.in/yaml.v3 v3.0.1
+	k8s.io/api v0.34.0
 	k8s.io/apiextensions-apiserver v0.34.0
 	k8s.io/apimachinery v0.34.0
 	k8s.io/cli-runtime v0.34.0
 	k8s.io/client-go v0.34.0
 	k8s.io/klog/v2 v2.130.1
+	sigs.k8s.io/yaml v1.6.0
 )
 
 require (
@@ -48,14 +52,12 @@ require (
 	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/oauth2 v0.27.0 // indirect
 	golang.org/x/sync v0.12.0 // indirect
-	golang.org/x/sys v0.31.0 // indirect
 	golang.org/x/term v0.30.0 // indirect
 	golang.org/x/text v0.23.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
 	google.golang.org/protobuf v1.36.5 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
-	k8s.io/api v0.34.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
 	k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
 	sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
@@ -63,5 +65,4 @@ require (
 	sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
 	sigs.k8s.io/randfill v1.0.0 // indirect
 	sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
-	sigs.k8s.io/yaml v1.6.0 // indirect
 )
diff --git a/clitools/go.sum b/clitools/go.sum
index 801d9e3..5f0d86e 100644
--- a/clitools/go.sum
+++ b/clitools/go.sum
@@ -50,6 +50,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
diff --git a/clitools/makefile b/clitools/makefile
index 10e2719..d55fce5 100644
--- a/clitools/makefile
+++ b/clitools/makefile
@@ -2,7 +2,7 @@
 VERSION ?= dev
 
 # Target kube version
-KUBE_VERSION ?= v1.34.1
+KUBE_VERSION ?= v1.35.0
 
 GIT_REV := $(shell git rev-parse HEAD)
 
@@ -41,8 +41,8 @@ build-local: .buildinfo
 	mkdir -p $(BIN_DIR)
 	go build -o $(BIN_DIR)/ctl-$(VERSION) ./cmd/ctl
 
-run:
-	go run ./cmd/ctl
+run-agent:
+	go run -tags dev ./cmd/ctl agent --env-file ./out/cluster.env
 
 clean:
 	-docker image rm localhost/monok8s/control-agent:$(VERSION)
diff --git a/clitools/pkg/catalog/checksums.go b/clitools/pkg/catalog/checksums.go
new file mode 100644
index 0000000..ca19221
--- /dev/null
+++ b/clitools/pkg/catalog/checksums.go
@@ -0,0 +1,30 @@
+package catalog
+
+import (
+	"encoding/hex"
+	"fmt"
+	"strings"
+)
+
+func (c *CatalogImage) SHA256() (string, error) {
+	if c.Checksum == "" {
+		return "", fmt.Errorf("checksum is empty")
+	}
+
+	const prefix = "sha256:"
+	if !strings.HasPrefix(c.Checksum, prefix) {
+		return "", fmt.Errorf("unsupported checksum format (expected sha256:...)")
+	}
+
+	hash := strings.TrimPrefix(c.Checksum, prefix)
+
+	if len(hash) != 64 {
+		return "", fmt.Errorf("invalid sha256 length: got %d, want 64", len(hash))
+	}
+
+	if _, err := hex.DecodeString(hash); err != nil {
+		return "", fmt.Errorf("invalid sha256 hex: %w", err)
+	}
+
+	return hash, nil
+}
diff --git a/clitools/pkg/catalog/resolver.go b/clitools/pkg/catalog/resolver.go
index f9e347d..f5fca21 100644
--- a/clitools/pkg/catalog/resolver.go
+++ b/clitools/pkg/catalog/resolver.go
@@ -21,19 +21,17 @@ const (
 
 // ResolveCatalog resolves catalog using priority:
 // Inline > ConfigMap > URL > cached
-func ResolveCatalog(
-	ctx context.Context,
+func ResolveCatalog(ctx context.Context,
 	kubeClient kubernetes.Interface,
-	namespace string,
-	src *monov1alpha1.VersionCatalogSource,
+	namespace string, src *monov1alpha1.VersionCatalogSource,
 ) (*VersionCatalog, error) {
 
-	// 1. Inline
+	// Inline
 	if src != nil && src.Inline != "" {
 		return parseCatalog([]byte(src.Inline))
 	}
 
-	// 2. ConfigMap
+	// ConfigMap
 	if src != nil && src.ConfigMap != "" {
 		cm, err := kubeClient.CoreV1().ConfigMaps(namespace).Get(ctx, src.ConfigMap, metav1.GetOptions{})
 		if err != nil {
@@ -48,7 +46,7 @@ func ResolveCatalog(
 		return parseCatalog([]byte(data))
 	}
 
-	// 3. URL
+	// URL
 	if src != nil && src.URL != "" {
 		cat, err := fetchCatalog(src.URL)
 		if err == nil {
@@ -64,7 +62,7 @@ func ResolveCatalog(
 		return nil, fmt.Errorf("fetch catalog failed and no cache: %w", err)
 	}
 
-	// 4. cached fallback
+	// fallback cache
 	if cached, err := loadCached(); err == nil {
 		return cached, nil
 	}
diff --git a/clitools/pkg/catalog/types.go b/clitools/pkg/catalog/types.go
index e78f0a9..ada01f2 100644
--- a/clitools/pkg/catalog/types.go
+++ b/clitools/pkg/catalog/types.go
@@ -10,4 +10,5 @@ type CatalogImage struct {
 	Version  string `json:"version" yaml:"version"`
 	URL      string `json:"url" yaml:"url"`
 	Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"`
+	Size     int64  `json:"size,omitempty" yaml:"size,omitempty"`
 }
diff --git a/clitools/pkg/cmd/agent/agent.go b/clitools/pkg/cmd/agent/agent.go
index edd050c..cc5e676 100644
--- a/clitools/pkg/cmd/agent/agent.go
+++ b/clitools/pkg/cmd/agent/agent.go
@@ -190,10 +190,7 @@ func matchesNode(osu *monov1alpha1.OSUpgrade, nodeName string, nodeLabels labels
 		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,
-	})
+	return false
 }
 
 func statusPhase(st *monov1alpha1.OSUpgradeStatus) string {
diff --git a/clitools/pkg/cmd/initcmd/init.go b/clitools/pkg/cmd/initcmd/init.go
index 991ade0..4bab9a8 100644
--- a/clitools/pkg/cmd/initcmd/init.go
+++ b/clitools/pkg/cmd/initcmd/init.go
@@ -69,7 +69,7 @@ Supported formats:
 				}
 			}
 
-			var cfg *monov1alpha1.MonoKSConfig // or value, depending on your API
+			var cfg *monov1alpha1.MonoKSConfig
 
 			switch {
 			case strings.TrimSpace(envFile) != "":
diff --git a/clitools/pkg/controller/osimage/apply.go b/clitools/pkg/controller/osimage/apply.go
new file mode 100644
index 0000000..7136f25
--- /dev/null
+++ b/clitools/pkg/controller/osimage/apply.go
@@ -0,0 +1,42 @@
+package osimage
+
+import (
+	"context"
+	"fmt"
+)
+
+func ApplyImageStreamed(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) {
+	if err := ValidateApplyOptions(opts); err != nil {
+		return nil, err
+	}
+
+	if err := CheckTargetSafe(opts.TargetPath, opts.ExpectedRawSize); err != nil {
+		return nil, fmt.Errorf("unsafe target %q: %w", opts.TargetPath, err)
+	}
+
+	src, closeFn, err := OpenDecompressedHTTPStream(ctx, opts.URL, opts.HTTPTimeout)
+	if err != nil {
+		return nil, fmt.Errorf("open source stream: %w", err)
+	}
+	defer closeFn()
+
+	written, err := WriteStreamToTarget(ctx, src, opts.TargetPath, opts.ExpectedRawSize, opts.BufferSize, opts.Progress)
+	if err != nil {
+		return nil, fmt.Errorf("write target: %w", err)
+	}
+
+	sum, err := VerifyTargetSHA256(ctx, opts.TargetPath, opts.ExpectedRawSize, opts.BufferSize, opts.Progress)
+	if err != nil {
+		return nil, fmt.Errorf("verify target: %w", err)
+	}
+
+	if err := VerifySHA256(sum, opts.ExpectedRawSHA256); err != nil {
+		return nil, fmt.Errorf("final disk checksum mismatch: %w", err)
+	}
+
+	return &ApplyResult{
+		BytesWritten:   written,
+		VerifiedSHA256: sum,
+		VerificationOK: true,
+	}, nil
+}
diff --git a/clitools/pkg/controller/osimage/helpers.go b/clitools/pkg/controller/osimage/helpers.go
new file mode 100644
index 0000000..505c573
--- /dev/null
+++ b/clitools/pkg/controller/osimage/helpers.go
@@ -0,0 +1,15 @@
+package osimage
+
+func PercentOf(done, total int64) int64 {
+	if total <= 0 {
+		return 0
+	}
+	p := (done * 100) / total
+	if p < 0 {
+		return 0
+	}
+	if p > 100 {
+		return 100
+	}
+	return p
+}
diff --git a/clitools/pkg/controller/osimage/progress.go b/clitools/pkg/controller/osimage/progress.go
new file mode 100644
index 0000000..e393f20
--- /dev/null
+++ b/clitools/pkg/controller/osimage/progress.go
@@ -0,0 +1,121 @@
+package osimage
+
+import (
+	"k8s.io/klog/v2"
+	"time"
+)
+
+type progressState struct {
+	lastTime    time.Time
+	lastPercent int64
+	lastBucket  int64
+}
+
+type ProgressLogger struct {
+	minInterval time.Duration
+	bucketSize  int64
+	states      map[string]*progressState
+}
+
+func NewProgressLogger(minSeconds int, bucketSize int64) *ProgressLogger {
+	if minSeconds < 0 {
+		minSeconds = 0
+	}
+	if bucketSize <= 0 {
+		bucketSize = 10
+	}
+
+	return &ProgressLogger{
+		minInterval: time.Duration(minSeconds) * time.Second,
+		bucketSize:  bucketSize,
+		states:      make(map[string]*progressState),
+	}
+}
+
+func (l *ProgressLogger) state(stage string) *progressState {
+	s, ok := l.states[stage]
+	if ok {
+		return s
+	}
+	s = &progressState{
+		lastPercent: -1,
+		lastBucket:  -1,
+	}
+	l.states[stage] = s
+	return s
+}
+
+func (l *ProgressLogger) Log(p Progress) {
+	if p.BytesTotal <= 0 {
+		return
+	}
+
+	percent := PercentOf(p.BytesComplete, p.BytesTotal)
+
+	now := time.Now()
+	bucket := percent / l.bucketSize
+	s := l.state(p.Stage)
+
+	// Always log first visible progress
+	if s.lastPercent == -1 {
+		s.lastPercent = percent
+		s.lastBucket = bucket
+		s.lastTime = now
+		klog.V(4).InfoS(p.Stage, "progress", percent)
+		return
+	}
+
+	// Always log completion once
+	if percent == 100 && s.lastPercent < 100 {
+		s.lastPercent = 100
+		s.lastBucket = 100 / l.bucketSize
+		s.lastTime = now
+		klog.V(4).InfoS(p.Stage, "progress", 100)
+		return
+	}
+
+	// Log if we crossed a new milestone bucket
+	if bucket > s.lastBucket {
+		s.lastPercent = percent
+		s.lastBucket = bucket
+		s.lastTime = now
+		klog.V(4).InfoS(p.Stage, "progress", percent)
+		return
+	}
+
+	// Otherwise allow a timed refresh if progress moved
+	if now.Sub(s.lastTime) >= l.minInterval && percent > s.lastPercent {
+		s.lastPercent = percent
+		s.lastTime = now
+		klog.V(4).InfoS(p.Stage, "progress", percent)
+	}
+}
+
+type TimeBasedUpdater struct {
+	interval time.Duration
+	lastRun  time.Time
+}
+
+func NewTimeBasedUpdater(seconds int) *TimeBasedUpdater {
+	if seconds <= 0 {
+		seconds = 15
+	}
+	return &TimeBasedUpdater{
+		interval: time.Duration(seconds) * time.Second,
+	}
+}
+
+func (u *TimeBasedUpdater) Run(fn func() error) error {
+	now := time.Now()
+
+	if !u.lastRun.IsZero() && now.Sub(u.lastRun) < u.interval {
+		return nil
+	}
+
+	if err := fn(); err != nil {
+		return err
+	}
+
+	u.lastRun = now
+	return nil
+}
diff --git a/clitools/pkg/controller/osimage/stream.go b/clitools/pkg/controller/osimage/stream.go
new file mode 100644
index 0000000..c1921ce
--- /dev/null
+++ b/clitools/pkg/controller/osimage/stream.go
@@ -0,0 +1,50 @@
+package osimage
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/klauspost/compress/zstd"
+)
+
+func OpenDecompressedHTTPStream(ctx context.Context, url string, timeout time.Duration) (io.Reader, func() error, error) {
+	if url == "" {
+		return nil, nil, fmt.Errorf("url is required")
+	}
+	if timeout <= 0 {
+		timeout = 30 * time.Minute
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("build request: %w", err)
+	}
+
+	client := &http.Client{Timeout: timeout}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, nil, fmt.Errorf("http get %q: %w", url, err)
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		resp.Body.Close()
+		return nil, nil, fmt.Errorf("unexpected status: %s", resp.Status)
+	}
+
+	dec, err := zstd.NewReader(resp.Body)
+	if err != nil {
+		resp.Body.Close()
+		return nil, nil, fmt.Errorf("create zstd decoder: %w", err)
+	}
+
+	closeFn := func() error {
+		dec.Close()
+		return resp.Body.Close()
+	}
+
+	return dec, closeFn, nil
+}
diff --git a/clitools/pkg/controller/osimage/types.go b/clitools/pkg/controller/osimage/types.go
new file mode 100644
index 0000000..8474cc8
--- /dev/null
+++ b/clitools/pkg/controller/osimage/types.go
@@ -0,0 +1,29 @@
+package osimage
+
+import "time"
+
+type ApplyOptions struct {
+	URL               string
+	TargetPath        string
+	ExpectedRawSHA256 string
+	ExpectedRawSize   int64
+
+	HTTPTimeout time.Duration
+	BufferSize  int
+
+	Progress ProgressFunc
+}
+
+type Progress struct {
+	Stage         string
+	BytesComplete int64
+	BytesTotal    int64
+}
+
+type ProgressFunc func(Progress)
+
+type ApplyResult struct {
+	BytesWritten   int64
+	VerifiedSHA256 string
+	VerificationOK bool
+}
diff --git a/clitools/pkg/controller/osimage/verify.go b/clitools/pkg/controller/osimage/verify.go
new file mode 100644
index 0000000..5b30318
--- /dev/null
+++ b/clitools/pkg/controller/osimage/verify.go
@@ -0,0 +1,104 @@
+package osimage
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+)
+
+func VerifyTargetSHA256(ctx context.Context, targetPath string, expectedSize int64,
+	bufferSize int, progress ProgressFunc) (string, error) {
+	if targetPath == "" {
+		return "", fmt.Errorf("target path is required")
+	}
+	if expectedSize <= 0 {
+		return "", fmt.Errorf("expected raw size is required for verification")
+	}
+	if bufferSize <= 0 {
+		bufferSize = 4 * 1024 * 1024
+	}
+
+	f, err := os.Open(targetPath)
+	if err != nil {
+		return "", fmt.Errorf("open target for verify: %w", err)
+	}
+	defer f.Close()
+
+	h := sha256.New()
+	buf := make([]byte, bufferSize)
+
+	var readTotal int64
+	limited := io.LimitReader(f, expectedSize)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		default:
+		}
+
+		n, err := limited.Read(buf)
+		if n > 0 {
+			if _, werr := h.Write(buf[:n]); werr != nil {
+				return "", fmt.Errorf("hash target: %w", werr)
+			}
+			readTotal += int64(n)
+			if progress != nil {
+				progress(Progress{
+					Stage:         "verify",
+					BytesComplete: readTotal,
+					BytesTotal:    expectedSize,
+				})
+			}
+		}
+
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return "", fmt.Errorf("read target: %w", err)
+		}
+	}
+
+	if readTotal != expectedSize {
+		return "", fmt.Errorf("verify size mismatch: got %d want %d", readTotal, expectedSize)
+	}
+
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+func ValidateApplyOptions(opts ApplyOptions) error {
+	if opts.URL == "" {
+		return fmt.Errorf("url is required")
+	}
+	if opts.TargetPath == "" {
+		return fmt.Errorf("target path is required")
+	}
+	if opts.ExpectedRawSHA256 == "" {
+		return fmt.Errorf("expected raw sha256 is required")
+	}
+	if opts.ExpectedRawSize <= 0 {
+		return fmt.Errorf("expected raw size must be > 0")
+	}
+	return nil
+}
+
+func VerifySHA256(got, expected string) error {
+	expected = NormalizeSHA256(expected)
+	if expected == "" {
+		return nil
+	}
+	got = NormalizeSHA256(got)
+	if got != expected {
+		return fmt.Errorf("sha256 mismatch: got %s want %s", got, expected)
+	}
+	return nil
+}
+
+func NormalizeSHA256(s string) string {
+	return strings.ToLower(strings.TrimSpace(s))
+}
diff --git a/clitools/pkg/controller/osimage/verify_safe.go b/clitools/pkg/controller/osimage/verify_safe.go
new file mode 100644
index 0000000..3a2e91a
--- /dev/null
+++ b/clitools/pkg/controller/osimage/verify_safe.go
@@ -0,0 +1,35 @@
+//go:build !dev
+
+package osimage
+
+import (
+	"fmt"
+	"os"
+	"strings"
+)
+
+func CheckTargetSafe(targetPath string, expectedRawSize int64) error {
+
+	if !strings.HasPrefix(targetPath, "/dev/") {
+		return fmt.Errorf("target must be a device path under /dev")
+	}
+
+	st, err := os.Stat(targetPath)
+	if err != nil {
+		return fmt.Errorf("stat target: %w", err)
+	}
+
+	mode := st.Mode()
+	if mode&os.ModeDevice == 0 {
+		return fmt.Errorf("target is not a device")
+	}
+
+	// TODO: Add stronger checks
+	// - EnsureNotMounted(targetPath)
+	// - EnsureNotCurrentRoot(targetPath)
+	// - EnsurePartitionNotWholeDisk(targetPath)
+	// - EnsureCapacity(targetPath, expectedRawSize)
+
+	_ = expectedRawSize
+	return nil
+}
diff --git a/clitools/pkg/controller/osimage/verify_unsafe.go b/clitools/pkg/controller/osimage/verify_unsafe.go
new file mode 100644
index 0000000..1111262
--- /dev/null
+++ b/clitools/pkg/controller/osimage/verify_unsafe.go
@@ -0,0 +1,7 @@
+//go:build dev
+
+package osimage
+
+func CheckTargetSafe(targetPath string, expectedRawSize int64) error {
+	return nil
+}
diff --git a/clitools/pkg/controller/osimage/write.go b/clitools/pkg/controller/osimage/write.go
new file mode 100644
index 0000000..b7e93f9
--- /dev/null
+++ b/clitools/pkg/controller/osimage/write.go
@@ -0,0 +1,82 @@
+package osimage
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+)
+
+func WriteStreamToTarget(ctx context.Context,
+	src io.Reader,
+	targetPath string,
+	expectedSize int64, bufferSize int,
+	progress ProgressFunc,
+) (int64, error) {
+	if targetPath == "" {
+		return 0, fmt.Errorf("target path is required")
+	}
+	if bufferSize <= 0 {
+		bufferSize = 4 * 1024 * 1024
+	}
+
+	f, err := os.OpenFile(targetPath, os.O_WRONLY, 0)
+	if err != nil {
+		return 0, fmt.Errorf("open target: %w", err)
+	}
+	defer f.Close()
+
+	written, err := copyWithProgressBuffer(ctx, f, src, expectedSize, "flash", progress, make([]byte, bufferSize))
+	if err != nil {
+		return written, err
+	}
+
+	if expectedSize > 0 && written != expectedSize {
+		return written, fmt.Errorf("written size mismatch: got %d want %d", written, expectedSize)
+	}
+
+	if err := f.Sync(); err != nil {
+		return written, fmt.Errorf("sync target: %w", err)
+	}
+
+	return written, nil
+}
+
+func copyWithProgressBuffer(ctx context.Context, dst io.Writer, src io.Reader, total int64, stage string, progress ProgressFunc, buf []byte) (int64, error) {
+	var written int64
+
+	for {
+		select {
+		case <-ctx.Done():
+			return written, ctx.Err()
+		default:
+		}
+
+		nr, er := src.Read(buf)
+		if nr > 0 {
+			nw, ew := dst.Write(buf[:nr])
+			if nw > 0 {
+				written += int64(nw)
+				if progress != nil {
+					progress(Progress{
+						Stage:         stage,
+						BytesComplete: written,
+						BytesTotal:    total,
+					})
+				}
+			}
+			if ew != nil {
+				return written, ew
+			}
+			if nw != nr {
+				return written, io.ErrShortWrite
+			}
+		}
+		if er != nil {
+			if er == io.EOF {
+				return written, nil
+			}
+			return written, fmt.Errorf("copy %s: %w", stage, er)
+		}
+	}
+}
diff --git a/clitools/pkg/controller/osupgrade/handler.go b/clitools/pkg/controller/osupgrade/handler.go
index 85378c5..57f587e 100644
--- a/clitools/pkg/controller/osupgrade/handler.go
+++ b/clitools/pkg/controller/osupgrade/handler.go
@@ -10,14 +10,12 @@ import (
 	monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1"
 	"example.com/monok8s/pkg/buildinfo"
 	"example.com/monok8s/pkg/catalog"
+	"example.com/monok8s/pkg/controller/osimage"
 	"example.com/monok8s/pkg/kube"
 )
 
-func HandleOSUpgrade(
-	ctx context.Context,
-	clients *kube.Clients,
-	namespace string,
-	nodeName string,
+func HandleOSUpgrade(ctx context.Context, clients *kube.Clients,
+	namespace string, nodeName string,
 	osu *monov1alpha1.OSUpgrade,
 ) error {
 	osup, err := ensureProgressHeartbeat(ctx, clients, namespace, nodeName, osu)
@@ -60,10 +58,12 @@ func HandleOSUpgrade(
 	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
+	osup, err = updateProgressStatus(ctx, clients, osup_gvr, osup)
 
-	if _, err := updateProgressStatus(ctx, clients, osup_gvr, osup); err != nil {
+	if err != nil {
 		return fmt.Errorf("update progress status: %w", err)
 	}
 
@@ -72,9 +72,63 @@ func HandleOSUpgrade(
 		"node", nodeName,
 		"resolvedTarget", plan.ResolvedTarget,
 		"steps", len(plan.Path),
-		"firstVersion", first.Version,
+		"currentVersion", buildinfo.KubeVersion,
+		"fSHA256irstVersion", first.Version,
 		"firstURL", first.URL,
+		"size", first.Size,
 	)
 
+	imageSHA, err := first.SHA256()
+	if err != nil {
+		now = metav1.Now()
+		return failProgress(ctx, clients, osup, "apply image", err)
+	}
+
+	pLogger := osimage.NewProgressLogger(2, 25)
+	statusUpdater := osimage.NewTimeBasedUpdater(15)
+
+	imageOptions := osimage.ApplyOptions{
+		URL:               first.URL,
+		TargetPath:        "./out/flash.img",
+		ExpectedRawSHA256: imageSHA,
+		ExpectedRawSize:   first.Size,
+		BufferSize:        6 * 1024 * 1024,
+		Progress: func(p osimage.Progress) {
+			pLogger.Log(p)
+			if err := statusUpdater.Run(func() error {
+
+				now := metav1.Now()
+				switch p.Stage {
+				case "flash":
+					osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseWriting
+				case "verify":
+					osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseVerifying
+				}
+				osup.Status.LastUpdatedAt = &now
+				osup.Status.Message = fmt.Sprintf("%s: %d%%", p.Stage, osimage.PercentOf(p.BytesComplete, p.BytesTotal))
+
+				updated, err := updateProgressStatus(ctx, clients, osup_gvr, osup)
+				if err != nil {
+					klog.ErrorS(err, "update progress status")
+					return err
+				}
+
+				osup = updated
+				return nil
+			}); err != nil {
+				klog.ErrorS(err, "throttled progress update failed")
+			}
+		},
+	}
+
+	result, err := osimage.ApplyImageStreamed(ctx, imageOptions)
+	if err != nil {
+		now = metav1.Now()
+		return failProgress(ctx, clients, osup, "apply image", err)
+	}
+
+	klog.Info(result)
+	// TODO: fw_setenv
+
 	return nil
 }
diff --git a/clitools/pkg/controller/osupgrade/progress.go b/clitools/pkg/controller/osupgrade/progress.go
index 93f4588..a7c501d 100644
--- a/clitools/pkg/controller/osupgrade/progress.go
+++ b/clitools/pkg/controller/osupgrade/progress.go
@@ -26,11 +26,8 @@ var (
 	}
 )
 
-func ensureProgressHeartbeat(
-	ctx context.Context,
-	clients *kube.Clients,
-	namespace string,
-	nodeName string,
+func ensureProgressHeartbeat(ctx context.Context, clients *kube.Clients,
+	namespace string, nodeName string,
 	osu *monov1alpha1.OSUpgrade,
 ) (*monov1alpha1.OSUpgradeProgress, error) {
 
diff --git a/clitools/pkg/node/kubeadm.go b/clitools/pkg/node/kubeadm.go
index f0f3d11..cc01781 100644
--- a/clitools/pkg/node/kubeadm.go
+++ b/clitools/pkg/node/kubeadm.go
@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"gopkg.in/yaml.v3"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/discovery"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
@@ -363,39 +362,6 @@ func isSupportedWorkerSkew(clusterVersion, nodeVersion string) bool {
 	return false
 }
 
-// This should not try to taint the node directly here.
-// Just record intent and let a later reconcile step apply the taint.
-func markUnsupportedWorkerVersionSkew(nctx *NodeContext, clusterVersion, nodeVersion string) {
-	// Replace this with whatever state carrier you already use.
-	//
-	// Example:
-	// nctx.Metadata.UnsupportedWorkerVersionSkew = true
-	// nctx.Metadata.UnsupportedWorkerVersionSkewReason =
-	//     fmt.Sprintf("unsupported worker version skew: cluster=%s node=%s", clusterVersion, nodeVersion)
-
-	_ = nctx
-	_ = clusterVersion
-	_ = nodeVersion
-}
-
-// Optional helper if you want to probe readiness later through the API.
-// Keeping this here in case you want a very cheap liveness call elsewhere.
-func apiServerReady(ctx context.Context, kubeconfigPath string) error {
-	restCfg, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
-	if err != nil {
-		return err
-	}
-	restCfg.Timeout = 5 * time.Second
-
-	clientset, err := kubernetes.NewForConfig(restCfg)
-	if err != nil {
-		return err
-	}
-
-	_, err = clientset.CoreV1().Namespaces().Get(ctx, metav1.NamespaceSystem, metav1.GetOptions{})
-	return err
-}
-
 func ValidateRequiredImagesPresent(ctx context.Context, n *NodeContext) error {
 	if n.Config.Spec.SkipImageCheck {
 		klog.Infof("skipping image check (skipImageCheck=true)")