penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 87aa1d4b0ba1879529d978e39ff497cb38c30aaa52711210b9d3d67d72520a6e

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-03-27T10:34:53Z
subjectAdded some ctl boilerplate
commit 87aa1d4b0ba1879529d978e39ff497cb38c30aaa52711210b9d3d67d72520a6e
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-03-27T10:34:53Z

    Added some ctl boilerplate
---
 .gitignore                                  |   1 +
 README.md                                   |  46 ++--
 build.env                                   |   2 +-
 clitools/cmd/ctl/main.go                    |  15 ++
 clitools/go.mod                             |  67 ++++++
 clitools/go.sum                             | 200 ++++++++++++++++
 clitools/makefile                           |  14 ++
 clitools/pkg/apis/monok8s/v1alpha1/types.go | 142 ++++++++++++
 clitools/pkg/bootstrap/registry.go          |  64 +++++
 clitools/pkg/bootstrap/runner.go            |  58 +++++
 clitools/pkg/cmd/agent/agent.go             |  51 ++++
 clitools/pkg/cmd/apply/apply.go             |  54 +++++
 clitools/pkg/cmd/checkconfig/checkconfig.go |  30 +++
 clitools/pkg/cmd/create/create.go           |  31 +++
 clitools/pkg/cmd/initcmd/init.go            |  34 +++
 clitools/pkg/cmd/internal/internal.go       |  30 +++
 clitools/pkg/cmd/root/root.go               |  41 ++++
 clitools/pkg/config/config.go               | 134 +++++++++++
 clitools/pkg/crds/crds.go                   |  71 ++++++
 clitools/pkg/kube/clients.go                |  50 ++++
 clitools/pkg/node/context.go                |  15 ++
 clitools/pkg/node/crio.go                   |  22 ++
 clitools/pkg/node/kubeadm.go                |  36 +++
 clitools/pkg/node/kubelet.go                |  12 +
 clitools/pkg/node/metadata.go               |  37 +++
 clitools/pkg/node/network.go                |  91 ++++++++
 clitools/pkg/node/prereqs.go                |  27 +++
 clitools/pkg/system/helpers.go              |  55 +++++
 clitools/pkg/system/runner.go               | 346 ++++++++++++++++++++++++++++
 clitools/pkg/templates/templates.go         |  54 +++++
 30 files changed, 1812 insertions(+), 18 deletions(-)

diff --git a/.gitignore b/.gitignore
index a86299f..dca5a56 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+clitools/bin
 packages/
 out/
 *.swp
diff --git a/README.md b/README.md
index 66c24b5..86b3d31 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,17 @@
 # monok8s
 
-Kubernetes node firmware (built with Linux) for Mono Gateway Development Kit
+Alpine-based Kubernetes cluster image for Mono's Gateway Development Kit
 
 https://docs.mono.si/gateway-development-kit/getting-started
 
 ## DISCLAIMER
 USE AT YOUR OWN RISKS. I leverage ChatGPT heavily for this. I am testing them all by myself right now.
 
+## IMPORTANT NOTES
+ * The 3 RJ45 ports are label in eth1, eth2, eth0 respectively by the kernel (left to right)
+   * So `ip addr eth0` is your right most port
+ * If the fan stopped spinning. Unplug ASAP! Otherwise CPU temp goes to the moon.
+
 ## Build
 
 Find the latest package versions and update build.env
@@ -80,27 +85,34 @@ make itb        # Builds out/board.itb (contains the kernel and the initramfs)
  - Read-only OS
 
 ## Upgrade process
-Rough idea
-
-```bash
-./configure
-# - asks for some config for kubelet
-# - Join a cluster? Start a cluster?
-
-make release
-
-# Copy the new image to the upgrade-scheduler
-kubectl cp -n kube-system upgrade-scheduler:/tmp/upgrade.img
 
-# Upgrade scheduler reads the file that issue a self-reboot
-reboot
+We use a CRD with an agent to handle this. Our versions follows upstream's.
 
-# uboot to boot into partition B
+kubectl apply -f upgrade.yaml
+```yaml
+apiVersion: k8s.mono.si/v1alpha1
+kind: OSUpgrade
+metadata:
+  name: "my-ugrade"
+spec:
+  version: "v1.35.1" # Or just "latest"
+  imageURL: "https://updates.example.com/monok8s-1.2.3.img.zst"
+  checksum: "sha256:..."
+```
 
+kubectl get osugrades
+```
+NAME           TARGET     STATUS      AGE
+my-upgrade-1   latest     pending     1m
+my-upgrade-2   v1.35.3    accepted    1m # latest gets realized into a version number
+their-upgrade  v1.33.2    succeeded   1m
 ```
 
-```yaml
-PENDING
+kubectl get osupgradeprogress
+```
+NODE        SOURCE         STATUS
+my-node     my-upgrade-2   active
+their-node  my-upgrade-2   completed
 ```
 
 ## NOTES
diff --git a/build.env b/build.env
index ca04aed..979202f 100644
--- a/build.env
+++ b/build.env
@@ -6,7 +6,7 @@ TAG=dev
 # The Linux kernel, from NXP
 NXP_VERSION=lf-6.18.2-1.0.0
 CRIO_VERSION=cri-o.arm64.v1.35.1
-KUBE_VERSION=v1.35.3
+KUBE_VERSION=v1.35.1
 
 # 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/cmd/ctl/main.go b/clitools/cmd/ctl/main.go
new file mode 100644
index 0000000..ed05cba
--- /dev/null
+++ b/clitools/cmd/ctl/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"undecided.project/monok8s/pkg/cmd/root"
+)
+
+func main() {
+	if err := root.NewRootCmd().Execute(); err != nil {
+		fmt.Fprintf(os.Stderr, "error: %v\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/clitools/go.mod b/clitools/go.mod
new file mode 100644
index 0000000..132991e
--- /dev/null
+++ b/clitools/go.mod
@@ -0,0 +1,67 @@
+module undecided.project/monok8s
+
+go 1.24.0
+
+require (
+	github.com/spf13/cobra v1.9.1
+	gopkg.in/yaml.v3 v3.0.1
+	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
+)
+
+require (
+	github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+	github.com/blang/semver/v4 v4.0.0 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/emicklei/go-restful/v3 v3.12.2 // indirect
+	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+	github.com/go-errors/errors v1.4.2 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-openapi/jsonpointer v0.21.0 // indirect
+	github.com/go-openapi/jsonreference v0.20.2 // indirect
+	github.com/go-openapi/swag v0.23.0 // indirect
+	github.com/gogo/protobuf v1.3.2 // indirect
+	github.com/google/btree v1.1.3 // indirect
+	github.com/google/gnostic-models v0.7.0 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/moby/term v0.5.0 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/spf13/pflag v1.0.6 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
+	github.com/xlab/treeprint v1.2.0 // indirect
+	go.yaml.in/yaml/v2 v2.4.2 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
+	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
+	sigs.k8s.io/kustomize/api v0.20.1 // indirect
+	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
new file mode 100644
index 0000000..801d9e3
--- /dev/null
+++ b/clitools/go.sum
@@ -0,0 +1,200 @@
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
+github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
+github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+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/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=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
+github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
+github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
+github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
+github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
+github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
+golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
+golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
+golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
+golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
+gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
+k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
+k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc=
+k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0=
+k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
+k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
+k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw=
+k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8=
+k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
+k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
+k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I=
+sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM=
+sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78=
+sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/clitools/makefile b/clitools/makefile
new file mode 100644
index 0000000..fac06f2
--- /dev/null
+++ b/clitools/makefile
@@ -0,0 +1,14 @@
+VERSION ?= dev
+
+BIN_DIR := bin
+
+build:
+	mkdir -p $(BIN_DIR)
+	go build -o $(BIN_DIR)/ctl-$(VERSION) ./cmd/ctl
+
+run:
+	go run ./cmd/ctl
+
+clean:
+	rm -rf $(BIN_DIR)
+
diff --git a/clitools/pkg/apis/monok8s/v1alpha1/types.go b/clitools/pkg/apis/monok8s/v1alpha1/types.go
new file mode 100644
index 0000000..51ddb30
--- /dev/null
+++ b/clitools/pkg/apis/monok8s/v1alpha1/types.go
@@ -0,0 +1,142 @@
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+const (
+	Group   = "monok8s.io"
+	Version = "v1alpha1"
+)
+
+var (
+	SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
+	SchemeBuilder      = runtime.NewSchemeBuilder(addKnownTypes)
+	AddToScheme        = SchemeBuilder.AddToScheme
+)
+
+type MonoKSConfig struct {
+	metav1.TypeMeta   `json:",inline" yaml:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"`
+	Spec              MonoKSConfigSpec   `json:"spec,omitempty" yaml:"spec,omitempty"`
+	Status            MonoKSConfigStatus `json:"status,omitempty" yaml:"status,omitempty"`
+}
+
+type MonoKSConfigList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []MonoKSConfig `json:"items"`
+}
+
+type MonoKSConfigSpec struct {
+	KubernetesVersion             string            `json:"kubernetesVersion,omitempty" yaml:"kubernetesVersion,omitempty"`
+	NodeName                      string            `json:"nodeName,omitempty" yaml:"nodeName,omitempty"`
+	ClusterName                   string            `json:"clusterName,omitempty" yaml:"clusterName,omitempty"`
+	ClusterDomain                 string            `json:"clusterDomain,omitempty" yaml:"clusterDomain,omitempty"`
+	PodSubnet                     string            `json:"podSubnet,omitempty" yaml:"podSubnet,omitempty"`
+	ServiceSubnet                 string            `json:"serviceSubnet,omitempty" yaml:"serviceSubnet,omitempty"`
+	APIServerAdvertiseAddress     string            `json:"apiServerAdvertiseAddress,omitempty" yaml:"apiServerAdvertiseAddress,omitempty"`
+	APIServerEndpoint             string            `json:"apiServerEndpoint,omitempty" yaml:"apiServerEndpoint,omitempty"`
+	ContainerRuntimeEndpoint      string            `json:"containerRuntimeEndpoint,omitempty" yaml:"containerRuntimeEndpoint,omitempty"`
+	BootstrapMode                 string            `json:"bootstrapMode,omitempty" yaml:"bootstrapMode,omitempty"`
+	JoinKind                      string            `json:"joinKind,omitempty" yaml:"joinKind,omitempty"`
+	BootstrapToken                string            `json:"bootstrapToken,omitempty" yaml:"bootstrapToken,omitempty"`
+	DiscoveryTokenCACertHash      string            `json:"discoveryTokenCACertHash,omitempty" yaml:"discoveryTokenCACertHash,omitempty"`
+	ControlPlaneCertKey           string            `json:"controlPlaneCertKey,omitempty" yaml:"controlPlaneCertKey,omitempty"`
+	CNIPlugin                     string            `json:"cniPlugin,omitempty" yaml:"cniPlugin,omitempty"`
+	AllowSchedulingOnControlPlane bool              `json:"allowSchedulingOnControlPlane,omitempty" yaml:"allowSchedulingOnControlPlane,omitempty"`
+	SkipImageCheck                bool              `json:"skipImageCheck,omitempty" yaml:"skipImageCheck,omitempty"`
+	KubeProxyNodePortAddresses    []string          `json:"kubeProxyNodePortAddresses,omitempty" yaml:"kubeProxyNodePortAddresses,omitempty"`
+	SubjectAltNames               []string          `json:"subjectAltNames,omitempty" yaml:"subjectAltNames,omitempty"`
+	NodeLabels                    map[string]string `json:"nodeLabels,omitempty" yaml:"nodeLabels,omitempty"`
+	NodeAnnotations               map[string]string `json:"nodeAnnotations,omitempty" yaml:"nodeAnnotations,omitempty"`
+	Network                       NetworkSpec       `json:"network,omitempty" yaml:"network,omitempty"`
+}
+
+type NetworkSpec struct {
+	Hostname         string   `json:"hostname,omitempty" yaml:"hostname,omitempty"`
+	ManagementIface  string   `json:"managementIface,omitempty" yaml:"managementIface,omitempty"`
+	ManagementCIDR   string   `json:"managementCIDR,omitempty" yaml:"managementCIDR,omitempty"`
+	ManagementGW     string   `json:"managementGateway,omitempty" yaml:"managementGateway,omitempty"`
+	DNSNameservers   []string `json:"dnsNameservers,omitempty" yaml:"dnsNameservers,omitempty"`
+	DNSSearchDomains []string `json:"dnsSearchDomains,omitempty" yaml:"dnsSearchDomains,omitempty"`
+}
+
+type MonoKSConfigStatus struct {
+	Phase              string             `json:"phase,omitempty"`
+	ObservedGeneration int64              `json:"observedGeneration,omitempty"`
+	Conditions         []metav1.Condition `json:"conditions,omitempty"`
+	AppliedSteps       []string           `json:"appliedSteps,omitempty"`
+}
+
+type OSUpgrade struct {
+	metav1.TypeMeta   `json:",inline" yaml:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"`
+	Spec              OSUpgradeSpec   `json:"spec,omitempty" yaml:"spec,omitempty"`
+	Status            OSUpgradeStatus `json:"status,omitempty" yaml:"status,omitempty"`
+}
+
+type OSUpgradeList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []OSUpgrade `json:"items"`
+}
+
+type OSUpgradeSpec struct {
+	Version         string   `json:"version,omitempty" yaml:"version,omitempty"`
+	ImageURL        string   `json:"imageURL,omitempty" yaml:"imageURL,omitempty"`
+	TargetPartition string   `json:"targetPartition,omitempty" yaml:"targetPartition,omitempty"`
+	NodeSelector    []string `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"`
+	Force           bool     `json:"force,omitempty" yaml:"force,omitempty"`
+}
+
+type OSUpgradeStatus struct {
+	Phase              string             `json:"phase,omitempty"`
+	ObservedGeneration int64              `json:"observedGeneration,omitempty"`
+	Conditions         []metav1.Condition `json:"conditions,omitempty"`
+}
+
+func addKnownTypes(scheme *runtime.Scheme) error {
+	scheme.AddKnownTypes(SchemeGroupVersion,
+		&MonoKSConfig{},
+		&MonoKSConfigList{},
+		&OSUpgrade{},
+		&OSUpgradeList{},
+	)
+	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
+	return nil
+}
+
+func (in *MonoKSConfig) DeepCopyObject() runtime.Object {
+	if in == nil {
+		return nil
+	}
+	out := *in
+	return &out
+}
+
+func (in *MonoKSConfigList) DeepCopyObject() runtime.Object {
+	if in == nil {
+		return nil
+	}
+	out := *in
+	return &out
+}
+
+func (in *OSUpgrade) DeepCopyObject() runtime.Object {
+	if in == nil {
+		return nil
+	}
+	out := *in
+	return &out
+}
+
+func (in *OSUpgradeList) DeepCopyObject() runtime.Object {
+	if in == nil {
+		return nil
+	}
+	out := *in
+	return &out
+}
diff --git a/clitools/pkg/bootstrap/registry.go b/clitools/pkg/bootstrap/registry.go
new file mode 100644
index 0000000..372a73f
--- /dev/null
+++ b/clitools/pkg/bootstrap/registry.go
@@ -0,0 +1,64 @@
+package bootstrap
+
+import (
+	"fmt"
+
+	"undecided.project/monok8s/pkg/node"
+)
+
+type Registry struct {
+	steps map[string]node.Step
+}
+
+func NewRegistry(ctx *node.NodeContext) *Registry {
+	netCfg := node.NetworkConfig{
+		MgmtIface:   ctx.Config.Spec.Network.ManagementIface,
+		MgmtAddress: ctx.Config.Spec.Network.ManagementCIDR,
+		MgmtGateway: ctx.Config.Spec.Network.ManagementGW,
+	}
+
+	return &Registry{
+		steps: map[string]node.Step{
+			"check_prereqs":                         node.CheckPrereqs,
+			"validate_network_requirements":         node.ValidateNetworkRequirements,
+			"install_cni_if_requested":              node.InstallCNIIfRequested,
+			"start_crio":                            node.StartCRIO,
+			"check_crio_running":                    node.CheckCRIORunning,
+			"wait_for_existing_cluster_if_needed":   node.WaitForExistingClusterIfNeeded,
+			"decide_bootstrap_action":               node.DecideBootstrapAction,
+			"check_required_images":                 node.CheckRequiredImages,
+			"generate_kubeadm_config":               node.GenerateKubeadmConfig,
+			"run_kubeadm_init":                      node.RunKubeadmInit,
+			"restart_kubelet":                       node.RestartKubelet,
+			"apply_local_node_metadata_if_possible": node.ApplyLocalNodeMetadataIfPossible,
+			"allow_single_node_scheduling":          node.AllowSingleNodeScheduling,
+			"ensure_ip_forward":                     node.EnsureIPForward,
+			"configure_mgmt_interface":              node.ConfigureMgmtInterface(netCfg),
+			"configure_dns":                         node.ConfigureDNS,
+			"set_hostname_if_needed":                node.SetHostnameIfNeeded,
+			"print_summary":                         node.PrintSummary,
+			"reconcile_control_plane":               node.ReconcileControlPlane,
+			"check_upgrade_prereqs":                 node.CheckUpgradePrereqs,
+			"run_kubeadm_upgrade_apply":             node.RunKubeadmUpgradeApply,
+			"run_kubeadm_join":                      node.RunKubeadmJoin,
+			"reconcile_node":                        node.ReconcileNode,
+			"run_kubeadm_upgrade_node":              node.RunKubeadmUpgradeNode,
+		},
+	}
+}
+
+func (r *Registry) MustGet(name string) node.Step {
+	step, ok := r.steps[name]
+	if !ok {
+		panic(fmt.Sprintf("unknown step %q", name))
+	}
+	return step
+}
+
+func (r *Registry) Get(name string) (node.Step, error) {
+	step, ok := r.steps[name]
+	if !ok {
+		return nil, fmt.Errorf("unknown step %q", name)
+	}
+	return step, nil
+}
diff --git a/clitools/pkg/bootstrap/runner.go b/clitools/pkg/bootstrap/runner.go
new file mode 100644
index 0000000..f2a3c59
--- /dev/null
+++ b/clitools/pkg/bootstrap/runner.go
@@ -0,0 +1,58 @@
+package bootstrap
+
+import (
+	"context"
+
+	monov1alpha1 "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	"undecided.project/monok8s/pkg/node"
+	"undecided.project/monok8s/pkg/system"
+)
+
+type Runner struct {
+	NodeCtx  *node.NodeContext
+	Registry *Registry
+}
+
+func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
+	runnerCfg := system.RunnerConfig{}
+	nctx := &node.NodeContext{
+		Config: cfg,
+		System: system.NewRunner(runnerCfg),
+	}
+	return &Runner{
+		NodeCtx:  nctx,
+		Registry: NewRegistry(nctx),
+	}
+}
+
+func (r *Runner) Init(ctx context.Context) error {
+	for _, name := range []string{
+		"check_prereqs",
+		"validate_network_requirements",
+		"install_cni_if_requested",
+		"start_crio",
+		"check_crio_running",
+		"wait_for_existing_cluster_if_needed",
+		"decide_bootstrap_action",
+		"check_required_images",
+		"generate_kubeadm_config",
+		"run_kubeadm_init",
+		"restart_kubelet",
+		"apply_local_node_metadata_if_possible",
+		"allow_single_node_scheduling",
+		"print_summary",
+	} {
+		if err := r.RunNamedStep(ctx, name); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *Runner) RunNamedStep(ctx context.Context, name string) error {
+	step, err := r.Registry.Get(name)
+	if err != nil {
+		return err
+	}
+	return step(ctx, r.NodeCtx)
+}
diff --git a/clitools/pkg/cmd/agent/agent.go b/clitools/pkg/cmd/agent/agent.go
new file mode 100644
index 0000000..832dc9c
--- /dev/null
+++ b/clitools/pkg/cmd/agent/agent.go
@@ -0,0 +1,51 @@
+package agent
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	monov1alpha1 "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	"undecided.project/monok8s/pkg/kube"
+	"github.com/spf13/cobra"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/klog/v2"
+)
+
+func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command {
+	var namespace string
+	cmd := &cobra.Command{
+		Use:   "agent",
+		Short: "Watch OSUpgrade resources and do nothing for now",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			clients, err := kube.NewClients(flags)
+			if err != nil {
+				return 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):
+				}
+			}
+		},
+	}
+	cmd.Flags().StringVar(&namespace, "namespace", "kube-system", "namespace to watch")
+	return cmd
+}
+
+var _ = context.Background
+var _ = fmt.Sprintf
diff --git a/clitools/pkg/cmd/apply/apply.go b/clitools/pkg/cmd/apply/apply.go
new file mode 100644
index 0000000..e2330bb
--- /dev/null
+++ b/clitools/pkg/cmd/apply/apply.go
@@ -0,0 +1,54 @@
+package apply
+
+import (
+	"context"
+	"fmt"
+
+	"undecided.project/monok8s/pkg/crds"
+	"undecided.project/monok8s/pkg/kube"
+	"github.com/spf13/cobra"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/klog/v2"
+)
+
+func NewCmdApply(flags *genericclioptions.ConfigFlags) *cobra.Command {
+	cmd := &cobra.Command{Use: "apply", Short: "Apply MonoK8s resources"}
+	cmd.AddCommand(newCmdApplyCRDs(flags))
+	return cmd
+}
+
+func newCmdApplyCRDs(flags *genericclioptions.ConfigFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "crds",
+		Short: "Register the MonoKSConfig and OSUpgrade CRDs",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			clients, err := kube.NewClients(flags)
+			if err != nil {
+				return err
+			}
+			ctx := context.Background()
+			for _, wanted := range crds.Definitions() {
+				_, err := clients.APIExtensions.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, wanted, metav1.CreateOptions{})
+				if apierrors.IsAlreadyExists(err) {
+					current, getErr := clients.APIExtensions.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, wanted.Name, metav1.GetOptions{})
+					if getErr != nil {
+						return getErr
+					}
+					wanted.ResourceVersion = current.ResourceVersion
+					_, err = clients.APIExtensions.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, wanted, metav1.UpdateOptions{})
+				}
+				if err != nil {
+					return err
+				}
+				klog.InfoS("crd applied", "name", wanted.Name)
+			}
+			_, _ = fmt.Fprintln(cmd.OutOrStdout(), "CRDs applied")
+			return nil
+		},
+	}
+}
+
+var _ *apiextensionsv1.CustomResourceDefinition
diff --git a/clitools/pkg/cmd/checkconfig/checkconfig.go b/clitools/pkg/cmd/checkconfig/checkconfig.go
new file mode 100644
index 0000000..634fbaa
--- /dev/null
+++ b/clitools/pkg/cmd/checkconfig/checkconfig.go
@@ -0,0 +1,30 @@
+package checkconfig
+
+import (
+	"fmt"
+
+	"undecided.project/monok8s/pkg/config"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdCheckConfig() *cobra.Command {
+	var configPath string
+	cmd := &cobra.Command{
+		Use:   "checkconfig",
+		Short: "Validate a MonoKSConfig",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			path, err := (config.Loader{}).ResolvePath(configPath)
+			if err != nil {
+				return err
+			}
+			cfg, err := (config.Loader{}).Load(path)
+			if err != nil {
+				return err
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "OK: %s (%s / %s)\n", path, cfg.Spec.NodeName, cfg.Spec.KubernetesVersion)
+			return nil
+		},
+	}
+	cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to MonoKSConfig yaml")
+	return cmd
+}
diff --git a/clitools/pkg/cmd/create/create.go b/clitools/pkg/cmd/create/create.go
new file mode 100644
index 0000000..bc78ef9
--- /dev/null
+++ b/clitools/pkg/cmd/create/create.go
@@ -0,0 +1,31 @@
+package create
+
+import (
+	"fmt"
+
+	"undecided.project/monok8s/pkg/templates"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdCreate() *cobra.Command {
+	cmd := &cobra.Command{Use: "create", Short: "Create starter resources"}
+	cmd.AddCommand(
+		&cobra.Command{
+			Use:   "config",
+			Short: "Print a MonoKSConfig template",
+			RunE: func(cmd *cobra.Command, _ []string) error {
+				_, err := fmt.Fprint(cmd.OutOrStdout(), templates.MonoKSConfigYAML)
+				return err
+			},
+		},
+		&cobra.Command{
+			Use:   "osupgrade",
+			Short: "Print an OSUpgrade template",
+			RunE: func(cmd *cobra.Command, _ []string) error {
+				_, err := fmt.Fprint(cmd.OutOrStdout(), templates.OSUpgradeYAML)
+				return err
+			},
+		},
+	)
+	return cmd
+}
diff --git a/clitools/pkg/cmd/initcmd/init.go b/clitools/pkg/cmd/initcmd/init.go
new file mode 100644
index 0000000..4950ea8
--- /dev/null
+++ b/clitools/pkg/cmd/initcmd/init.go
@@ -0,0 +1,34 @@
+package initcmd
+
+import (
+	"context"
+
+	"undecided.project/monok8s/pkg/bootstrap"
+	"undecided.project/monok8s/pkg/config"
+	"github.com/spf13/cobra"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/klog/v2"
+)
+
+func NewCmdInit(_ *genericclioptions.ConfigFlags) *cobra.Command {
+	var configPath string
+	cmd := &cobra.Command{
+		Use:   "init",
+		Short: "Equivalent of apply-node-config + bootstrap-cluster",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			path, err := (config.Loader{}).ResolvePath(configPath)
+			if err != nil {
+				return err
+			}
+			cfg, err := (config.Loader{}).Load(path)
+			if err != nil {
+				return err
+			}
+			klog.InfoS("starting init", "config", path, "node", cfg.Spec.NodeName)
+			return bootstrap.NewRunner(cfg).Init(cmd.Context())
+		},
+	}
+	cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to MonoKSConfig yaml")
+	_ = context.Background()
+	return cmd
+}
diff --git a/clitools/pkg/cmd/internal/internal.go b/clitools/pkg/cmd/internal/internal.go
new file mode 100644
index 0000000..24f3684
--- /dev/null
+++ b/clitools/pkg/cmd/internal/internal.go
@@ -0,0 +1,30 @@
+package internal
+
+import (
+	"undecided.project/monok8s/pkg/bootstrap"
+	"undecided.project/monok8s/pkg/config"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdInternal() *cobra.Command {
+	var configPath string
+	cmd := &cobra.Command{Use: "internal", Hidden: true}
+	cmd.AddCommand(&cobra.Command{
+		Use:   "run-step STEP",
+		Short: "Run one internal step for testing",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			path, err := (config.Loader{}).ResolvePath(configPath)
+			if err != nil {
+				return err
+			}
+			cfg, err := (config.Loader{}).Load(path)
+			if err != nil {
+				return err
+			}
+			return bootstrap.NewRunner(cfg).RunNamedStep(cmd.Context(), args[0])
+		},
+	})
+	cmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to MonoKSConfig yaml")
+	return cmd
+}
diff --git a/clitools/pkg/cmd/root/root.go b/clitools/pkg/cmd/root/root.go
new file mode 100644
index 0000000..c1e1541
--- /dev/null
+++ b/clitools/pkg/cmd/root/root.go
@@ -0,0 +1,41 @@
+package root
+
+import (
+	"flag"
+
+	agentcmd "undecided.project/monok8s/pkg/cmd/agent"
+	applycmd "undecided.project/monok8s/pkg/cmd/apply"
+	checkconfigcmd "undecided.project/monok8s/pkg/cmd/checkconfig"
+	createcmd "undecided.project/monok8s/pkg/cmd/create"
+	initcmd "undecided.project/monok8s/pkg/cmd/initcmd"
+	internalcmd "undecided.project/monok8s/pkg/cmd/internal"
+	"github.com/spf13/cobra"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/klog/v2"
+)
+
+func NewRootCmd() *cobra.Command {
+	flags := genericclioptions.NewConfigFlags(true)
+
+	cmd := &cobra.Command{
+		Use:           "ctl",
+		Short:         "MonoK8s control tool",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		PersistentPreRun: func(*cobra.Command, []string) {
+			klog.InitFlags(nil)
+			_ = flag.Set("logtostderr", "true")
+		},
+	}
+
+	flags.AddFlags(cmd.PersistentFlags())
+	cmd.AddCommand(
+		initcmd.NewCmdInit(flags),
+		checkconfigcmd.NewCmdCheckConfig(),
+		createcmd.NewCmdCreate(),
+		applycmd.NewCmdApply(flags),
+		agentcmd.NewCmdAgent(flags),
+		internalcmd.NewCmdInternal(),
+	)
+	return cmd
+}
diff --git a/clitools/pkg/config/config.go b/clitools/pkg/config/config.go
new file mode 100644
index 0000000..f944e60
--- /dev/null
+++ b/clitools/pkg/config/config.go
@@ -0,0 +1,134 @@
+package config
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	monov1alpha1 "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	"gopkg.in/yaml.v3"
+)
+
+const EnvVar = "MONOKSCONFIG"
+
+type Loader struct{}
+
+func (Loader) ResolvePath(flagValue string) (string, error) {
+	if strings.TrimSpace(flagValue) != "" {
+		return flagValue, nil
+	}
+	if env := strings.TrimSpace(os.Getenv(EnvVar)); env != "" {
+		return env, nil
+	}
+	return "", fmt.Errorf("config path not provided; pass -c or set %s", EnvVar)
+}
+
+func (Loader) Load(path string) (*monov1alpha1.MonoKSConfig, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cfg monov1alpha1.MonoKSConfig
+	if err := yaml.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+	if cfg.Kind == "" {
+		cfg.Kind = "MonoKSConfig"
+	}
+	if cfg.APIVersion == "" {
+		cfg.APIVersion = monov1alpha1.Group + "/" + monov1alpha1.Version
+	}
+	ApplyDefaults(&cfg)
+	if err := Validate(&cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+func ApplyDefaults(cfg *monov1alpha1.MonoKSConfig) {
+	if cfg.Spec.PodSubnet == "" {
+		cfg.Spec.PodSubnet = "10.244.0.0/16"
+	}
+	if cfg.Spec.ServiceSubnet == "" {
+		cfg.Spec.ServiceSubnet = "10.96.0.0/12"
+	}
+	if cfg.Spec.ClusterName == "" {
+		cfg.Spec.ClusterName = "monok8s"
+	}
+	if cfg.Spec.ClusterDomain == "" {
+		cfg.Spec.ClusterDomain = "cluster.local"
+	}
+	if cfg.Spec.ContainerRuntimeEndpoint == "" {
+		cfg.Spec.ContainerRuntimeEndpoint = "unix:///var/run/crio/crio.sock"
+	}
+	if cfg.Spec.BootstrapMode == "" {
+		cfg.Spec.BootstrapMode = "init"
+	}
+	if cfg.Spec.JoinKind == "" {
+		cfg.Spec.JoinKind = "worker"
+	}
+	if cfg.Spec.CNIPlugin == "" {
+		cfg.Spec.CNIPlugin = "none"
+	}
+	if len(cfg.Spec.KubeProxyNodePortAddresses) == 0 {
+		cfg.Spec.KubeProxyNodePortAddresses = []string{"primary"}
+	}
+}
+
+func Validate(cfg *monov1alpha1.MonoKSConfig) error {
+	var problems []string
+	if cfg.Kind != "MonoKSConfig" {
+		problems = append(problems, "kind must be MonoKSConfig")
+	}
+	if cfg.APIVersion != monov1alpha1.Group+"/"+monov1alpha1.Version {
+		problems = append(problems, "apiVersion must be "+monov1alpha1.Group+"/"+monov1alpha1.Version)
+	}
+	if strings.TrimSpace(cfg.Spec.KubernetesVersion) == "" {
+		problems = append(problems, "spec.kubernetesVersion is required")
+	}
+	if strings.TrimSpace(cfg.Spec.NodeName) == "" {
+		problems = append(problems, "spec.nodeName is required")
+	}
+	if strings.TrimSpace(cfg.Spec.APIServerAdvertiseAddress) == "" {
+		problems = append(problems, "spec.apiServerAdvertiseAddress is required")
+	}
+	if strings.TrimSpace(cfg.Spec.Network.Hostname) == "" {
+		problems = append(problems, "spec.network.hostname is required")
+	}
+	if strings.TrimSpace(cfg.Spec.Network.ManagementIface) == "" {
+		problems = append(problems, "spec.network.managementIface is required")
+	}
+	if !strings.Contains(cfg.Spec.Network.ManagementCIDR, "/") {
+		problems = append(problems, "spec.network.managementCIDR must include a CIDR prefix")
+	}
+	if cfg.Spec.BootstrapMode != "init" && cfg.Spec.BootstrapMode != "join" {
+		problems = append(problems, "spec.bootstrapMode must be init or join")
+	}
+	if cfg.Spec.JoinKind != "worker" && cfg.Spec.JoinKind != "control-plane" {
+		problems = append(problems, "spec.joinKind must be worker or control-plane")
+	}
+	for _, ns := range cfg.Spec.Network.DNSNameservers {
+		if ns == "10.96.0.10" {
+			problems = append(problems, "spec.network.dnsNameservers must not include cluster DNS service IP 10.96.0.10")
+		}
+	}
+	if cfg.Spec.BootstrapMode == "join" {
+		if cfg.Spec.APIServerEndpoint == "" {
+			problems = append(problems, "spec.apiServerEndpoint is required for join mode")
+		}
+		if cfg.Spec.BootstrapToken == "" {
+			problems = append(problems, "spec.bootstrapToken is required for join mode")
+		}
+		if cfg.Spec.DiscoveryTokenCACertHash == "" {
+			problems = append(problems, "spec.discoveryTokenCACertHash is required for join mode")
+		}
+		if cfg.Spec.JoinKind == "control-plane" && cfg.Spec.ControlPlaneCertKey == "" {
+			problems = append(problems, "spec.controlPlaneCertKey is required for control-plane join")
+		}
+	}
+	if len(problems) > 0 {
+		return errors.New(strings.Join(problems, "; "))
+	}
+	return nil
+}
diff --git a/clitools/pkg/crds/crds.go b/clitools/pkg/crds/crds.go
new file mode 100644
index 0000000..9942a3a
--- /dev/null
+++ b/clitools/pkg/crds/crds.go
@@ -0,0 +1,71 @@
+package crds
+
+import (
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func Definitions() []*apiextensionsv1.CustomResourceDefinition {
+	return []*apiextensionsv1.CustomResourceDefinition{
+		monoKSConfigCRD(),
+		osUpgradeCRD(),
+	}
+}
+
+func monoKSConfigCRD() *apiextensionsv1.CustomResourceDefinition {
+	return &apiextensionsv1.CustomResourceDefinition{
+		ObjectMeta: metav1.ObjectMeta{Name: "monoksconfigs.monok8s.io"},
+		Spec: apiextensionsv1.CustomResourceDefinitionSpec{
+			Group: "monok8s.io",
+			Scope: apiextensionsv1.NamespaceScoped,
+			Names: apiextensionsv1.CustomResourceDefinitionNames{
+				Plural:     "monoksconfigs",
+				Singular:   "monoksconfig",
+				Kind:       "MonoKSConfig",
+				ShortNames: []string{"mkscfg"},
+			},
+			Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
+				Name:    "v1alpha1",
+				Served:  true,
+				Storage: true,
+				Schema: &apiextensionsv1.CustomResourceValidation{OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
+					Type: "object",
+					Properties: map[string]apiextensionsv1.JSONSchemaProps{
+						"spec":   {Type: "object", XPreserveUnknownFields: boolPtr(true)},
+						"status": {Type: "object", XPreserveUnknownFields: boolPtr(true)},
+					},
+				}},
+			}},
+		},
+	}
+}
+
+func osUpgradeCRD() *apiextensionsv1.CustomResourceDefinition {
+	return &apiextensionsv1.CustomResourceDefinition{
+		ObjectMeta: metav1.ObjectMeta{Name: "osupgrades.monok8s.io"},
+		Spec: apiextensionsv1.CustomResourceDefinitionSpec{
+			Group: "monok8s.io",
+			Scope: apiextensionsv1.NamespaceScoped,
+			Names: apiextensionsv1.CustomResourceDefinitionNames{
+				Plural:     "osupgrades",
+				Singular:   "osupgrade",
+				Kind:       "OSUpgrade",
+				ShortNames: []string{"osup"},
+			},
+			Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
+				Name:    "v1alpha1",
+				Served:  true,
+				Storage: true,
+				Schema: &apiextensionsv1.CustomResourceValidation{OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
+					Type: "object",
+					Properties: map[string]apiextensionsv1.JSONSchemaProps{
+						"spec":   {Type: "object", XPreserveUnknownFields: boolPtr(true)},
+						"status": {Type: "object", XPreserveUnknownFields: boolPtr(true)},
+					},
+				}},
+			}},
+		},
+	}
+}
+
+func boolPtr(v bool) *bool { return &v }
diff --git a/clitools/pkg/kube/clients.go b/clitools/pkg/kube/clients.go
new file mode 100644
index 0000000..e75dd04
--- /dev/null
+++ b/clitools/pkg/kube/clients.go
@@ -0,0 +1,50 @@
+package kube
+
+import (
+	"fmt"
+
+	monov1alpha1 "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/dynamic"
+	kubernetes "k8s.io/client-go/kubernetes"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+)
+
+type Clients struct {
+	Config           *rest.Config
+	Kubernetes       kubernetes.Interface
+	Dynamic          dynamic.Interface
+	APIExtensions    apiextensionsclientset.Interface
+	RESTClientGetter genericclioptions.RESTClientGetter
+}
+
+func NewClients(flags *genericclioptions.ConfigFlags) (*Clients, error) {
+	cfg, err := flags.ToRESTConfig()
+	if err != nil {
+		return nil, fmt.Errorf("build rest config: %w", err)
+	}
+	kubeClient, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("build kubernetes client: %w", err)
+	}
+	dyn, err := dynamic.NewForConfig(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("build dynamic client: %w", err)
+	}
+	ext, err := apiextensionsclientset.NewForConfig(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("build apiextensions client: %w", err)
+	}
+	return &Clients{Config: cfg, Kubernetes: kubeClient, Dynamic: dyn, APIExtensions: ext, RESTClientGetter: flags}, nil
+}
+
+func Scheme() *runtime.Scheme {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(monov1alpha1.AddToScheme(scheme))
+	return scheme
+}
diff --git a/clitools/pkg/node/context.go b/clitools/pkg/node/context.go
new file mode 100644
index 0000000..d308b4f
--- /dev/null
+++ b/clitools/pkg/node/context.go
@@ -0,0 +1,15 @@
+package node
+
+import (
+	"context"
+
+	monov1alpha1 "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	"undecided.project/monok8s/pkg/system"
+)
+
+type NodeContext struct {
+	Config *monov1alpha1.MonoKSConfig
+	System *system.Runner
+}
+
+type Step func(context.Context, *NodeContext) error
diff --git a/clitools/pkg/node/crio.go b/clitools/pkg/node/crio.go
new file mode 100644
index 0000000..a99f261
--- /dev/null
+++ b/clitools/pkg/node/crio.go
@@ -0,0 +1,22 @@
+package node
+
+import (
+	"context"
+
+	"k8s.io/klog/v2"
+)
+
+func InstallCNIIfRequested(context.Context, *NodeContext) error {
+	klog.Info("install_cni_if_requested: TODO implement bridge/none CNI toggling")
+	return nil
+}
+
+func StartCRIO(context.Context, *NodeContext) error {
+	klog.Info("start_crio: TODO implement rc-service crio start")
+	return nil
+}
+
+func CheckCRIORunning(context.Context, *NodeContext) error {
+	klog.Info("check_crio_running: TODO implement crictl readiness checks")
+	return nil
+}
diff --git a/clitools/pkg/node/kubeadm.go b/clitools/pkg/node/kubeadm.go
new file mode 100644
index 0000000..1a3b757
--- /dev/null
+++ b/clitools/pkg/node/kubeadm.go
@@ -0,0 +1,36 @@
+package node
+
+import (
+    "context"
+
+    "k8s.io/klog/v2"
+)
+
+func WaitForExistingClusterIfNeeded(context.Context, *NodeContext) error {
+    klog.Info("wait_for_existing_cluster_if_needed: TODO implement kubelet/admin.conf waits")
+    return nil
+}
+func CheckRequiredImages(context.Context, *NodeContext) error {
+    klog.Info("check_required_images: TODO implement kubeadm image list + crictl image presence")
+    return nil
+}
+func GenerateKubeadmConfig(context.Context, *NodeContext) error {
+    klog.Info("generate_kubeadm_config: TODO render kubeadm v1beta4 config from MonoKSConfig")
+    return nil
+}
+func RunKubeadmInit(context.Context, *NodeContext) error {
+    klog.Info("run_kubeadm_init: TODO implement kubeadm init --config <file>")
+    return nil
+}
+func RunKubeadmUpgradeApply(context.Context, *NodeContext) error {
+    klog.Info("run_kubeadm_upgrade_apply: TODO implement kubeadm upgrade apply")
+    return nil
+}
+func RunKubeadmJoin(context.Context, *NodeContext) error {
+    klog.Info("run_kubeadm_join: TODO implement kubeadm join")
+    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/kubelet.go b/clitools/pkg/node/kubelet.go
new file mode 100644
index 0000000..a7003fd
--- /dev/null
+++ b/clitools/pkg/node/kubelet.go
@@ -0,0 +1,12 @@
+package node
+
+import (
+    "context"
+
+    "k8s.io/klog/v2"
+)
+
+func RestartKubelet(context.Context, *NodeContext) error {
+    klog.Info("restart_kubelet: TODO implement rc-service kubelet restart")
+    return nil
+}
diff --git a/clitools/pkg/node/metadata.go b/clitools/pkg/node/metadata.go
new file mode 100644
index 0000000..cbd869b
--- /dev/null
+++ b/clitools/pkg/node/metadata.go
@@ -0,0 +1,37 @@
+package node
+
+import (
+    "context"
+
+    "k8s.io/klog/v2"
+)
+
+func ApplyLocalNodeMetadataIfPossible(context.Context, *NodeContext) error {
+    klog.Info("apply_local_node_metadata_if_possible: TODO implement node labels/annotations")
+    return nil
+}
+
+func AllowSingleNodeScheduling(context.Context, *NodeContext) error {
+    klog.Info("allow_single_node_scheduling: TODO implement control-plane taint removal")
+    return nil
+}
+
+func SetHostnameIfNeeded(context.Context, *NodeContext) error {
+    klog.Info("set_hostname_if_needed: TODO implement hostname and /etc/hostname reconciliation")
+    return nil
+}
+
+func PrintSummary(context.Context, *NodeContext) error {
+    klog.Info("print_summary: TODO emit final summary")
+    return nil
+}
+
+func ReconcileControlPlane(context.Context, *NodeContext) error {
+    klog.Info("reconcile_control_plane: TODO implement existing CP reconciliation")
+    return nil
+}
+
+func ReconcileNode(context.Context, *NodeContext) error {
+    klog.Info("reconcile_node: TODO implement existing joined node reconciliation")
+    return nil
+}
diff --git a/clitools/pkg/node/network.go b/clitools/pkg/node/network.go
new file mode 100644
index 0000000..28609b3
--- /dev/null
+++ b/clitools/pkg/node/network.go
@@ -0,0 +1,91 @@
+package node
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"strings"
+
+	system "undecided.project/monok8s/pkg/system"
+	"k8s.io/klog/v2"
+)
+
+type NetworkConfig struct {
+	MgmtIface   string
+	MgmtAddress string
+	MgmtGateway string
+}
+
+func ConfigureMgmtInterface(cfg NetworkConfig) Step {
+	return func(ctx context.Context, nctx *NodeContext) error {
+		if strings.TrimSpace(cfg.MgmtIface) == "" {
+			return fmt.Errorf("mgmt interface is required")
+		}
+		if strings.TrimSpace(cfg.MgmtAddress) == "" {
+			return fmt.Errorf("mgmt address is required")
+		}
+		ip, ipNet, err := net.ParseCIDR(cfg.MgmtAddress)
+		if err != nil {
+			return fmt.Errorf("invalid mgmt address %q: %w", cfg.MgmtAddress, err)
+		}
+		wantIP := ip.String()
+		if gw := strings.TrimSpace(cfg.MgmtGateway); gw != "" && net.ParseIP(gw) == nil {
+			return fmt.Errorf("invalid mgmt gateway %q", gw)
+		}
+
+		if _, err := nctx.System.Run(ctx, "ip", "link", "show", "dev", cfg.MgmtIface); err != nil {
+			return fmt.Errorf("interface not found: %s: %w", cfg.MgmtIface, err)
+		}
+		if _, err := nctx.System.Run(ctx, "ip", "link", "set", "dev", cfg.MgmtIface, "up"); err != nil {
+			return fmt.Errorf("failed to bring up interface %s: %w", cfg.MgmtIface, err)
+		}
+
+		hasAddr, err := interfaceHasIPv4(ctx, nctx, cfg.MgmtIface, wantIP)
+		if err != nil {
+			return fmt.Errorf("failed checking existing address on %s: %w", cfg.MgmtIface, err)
+		}
+		if hasAddr {
+			klog.Infof("address already present on %s: %s", cfg.MgmtIface, cfg.MgmtAddress)
+		} else {
+			if _, err := nctx.System.Run(ctx, "ip", "addr", "add", ipNet.String(), "dev", cfg.MgmtIface); err != nil {
+				return fmt.Errorf("failed assigning %s to %s: %w", ipNet.String(), cfg.MgmtIface, err)
+			}
+		}
+
+		if gw := strings.TrimSpace(cfg.MgmtGateway); gw != "" {
+			if _, err := nctx.System.Run(ctx, "ip", "route", "replace", "default", "via", gw, "dev", cfg.MgmtIface); err != nil {
+				return fmt.Errorf("failed setting default route via %s dev %s: %w", gw, cfg.MgmtIface, err)
+			}
+		}
+		return nil
+	}
+}
+
+func EnsureIPForward(ctx context.Context, n *NodeContext) error {
+	return system.EnsureSysctl(ctx, n.System, "net.ipv4.ip_forward", "1")
+}
+
+func ConfigureDNS(context.Context, *NodeContext) error {
+	klog.Info("configure_dns: TODO implement resolv.conf rendering")
+	return nil
+}
+
+func interfaceHasIPv4(ctx context.Context, nctx *NodeContext, iface, wantIP string) (bool, error) {
+	res, err := nctx.System.Run(ctx, "ip", "-o", "-4", "addr", "show", "dev", iface)
+	if err != nil {
+		return false, err
+	}
+	for _, line := range strings.Split(res.Stdout, "\n") {
+		fields := strings.Fields(strings.TrimSpace(line))
+		for i := 0; i < len(fields)-1; i++ {
+			if fields[i] != "inet" {
+				continue
+			}
+			ip, _, err := net.ParseCIDR(fields[i+1])
+			if err == nil && ip.String() == wantIP {
+				return true, nil
+			}
+		}
+	}
+	return false, nil
+}
diff --git a/clitools/pkg/node/prereqs.go b/clitools/pkg/node/prereqs.go
new file mode 100644
index 0000000..b6599c4
--- /dev/null
+++ b/clitools/pkg/node/prereqs.go
@@ -0,0 +1,27 @@
+package node
+
+import (
+    "context"
+
+    "k8s.io/klog/v2"
+)
+
+func CheckPrereqs(context.Context, *NodeContext) error {
+    klog.Info("check_prereqs: TODO implement command discovery and runtime validation")
+    return nil
+}
+
+func ValidateNetworkRequirements(context.Context, *NodeContext) error {
+    klog.Info("validate_network_requirements: TODO implement local IP and API reachability checks")
+    return nil
+}
+
+func CheckUpgradePrereqs(context.Context, *NodeContext) error {
+    klog.Info("check_upgrade_prereqs: TODO implement kubeadm version / skew checks")
+    return nil
+}
+
+func DecideBootstrapAction(_ context.Context, nctx *NodeContext) error {
+    klog.InfoS("decide_bootstrap_action", "bootstrapMode", nctx.Config.Spec.BootstrapMode, "joinKind", nctx.Config.Spec.JoinKind)
+    return nil
+}
diff --git a/clitools/pkg/system/helpers.go b/clitools/pkg/system/helpers.go
new file mode 100644
index 0000000..6b13973
--- /dev/null
+++ b/clitools/pkg/system/helpers.go
@@ -0,0 +1,55 @@
+package system
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+)
+
+const DefaultSecond = 1_000_000_000
+
+func EnsureServiceRunning(ctx context.Context, r *Runner, svc string) error {
+	if _, err := r.Run(ctx, " rc-service", svc, "status"); err == nil {
+		return nil
+	}
+
+	_, err := r.RunRetry(ctx, RetryOptions{
+		Attempts: 3,
+		Delay:    2 * DefaultSecond,
+	}, "rc-service", svc, "start")
+	if err != nil {
+		return fmt.Errorf("failed to start service %q: %w", svc, err)
+	}
+
+	_, err = r.Run(ctx, "rc-service", svc, "status")
+	if err != nil {
+		return fmt.Errorf("service %q still not healthy after start: %w", svc, err)
+	}
+	return nil
+}
+
+func EnsureSysctl(ctx context.Context, r *Runner, key, want string) error {
+	_, err := r.Run(ctx, "sysctl", "-w", key+"="+want)
+	if err != nil {
+		return fmt.Errorf("failed setting sysctl %s=%s: %w", key, want, err)
+	}
+
+	// verify
+	path := "/proc/sys/" + strings.ReplaceAll(key, ".", "/")
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("failed verifying sysctl %s: %w", key, err)
+	}
+	if strings.TrimSpace(string(raw)) != want {
+		return fmt.Errorf("sysctl %s not applied, expected %s got %s",
+			key, want, strings.TrimSpace(string(raw)))
+	}
+
+	return nil
+}
+
+func EnsureDir(ctx context.Context, r *Runner, path string, mode string) error {
+	_, err := r.Run(ctx, "install", "-d", "-m", mode, path)
+	return err
+}
diff --git a/clitools/pkg/system/runner.go b/clitools/pkg/system/runner.go
new file mode 100644
index 0000000..4d3cda4
--- /dev/null
+++ b/clitools/pkg/system/runner.go
@@ -0,0 +1,346 @@
+package system
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+	"time"
+)
+
+type Logger interface {
+	Printf(format string, args ...any)
+}
+
+type RunnerConfig struct {
+	DefaultTimeout time.Duration
+	StreamOutput   bool
+	Logger         Logger
+}
+
+type Runner struct {
+	cfg RunnerConfig
+}
+
+func NewRunner(cfg RunnerConfig) *Runner {
+	if cfg.DefaultTimeout <= 0 {
+		cfg.DefaultTimeout = 30 * time.Second
+	}
+	return &Runner{cfg: cfg}
+}
+
+type Result struct {
+	Name      string
+	Args      []string
+	ExitCode  int
+	Stdout    string
+	Stderr    string
+	Duration  time.Duration
+	StartTime time.Time
+	EndTime   time.Time
+}
+
+type RunOptions struct {
+	Dir       string
+	Env       []string
+	Timeout   time.Duration
+	Stdin     io.Reader
+	Stdout    io.Writer
+	Stderr    io.Writer
+	Quiet     bool
+	RedactEnv []string
+}
+
+type RetryOptions struct {
+	Attempts int
+	Delay    time.Duration
+}
+
+func (r *Runner) Run(ctx context.Context, name string, args ...string) (*Result, error) {
+	return r.RunWithOptions(ctx, name, args, RunOptions{})
+}
+
+func (r *Runner) RunWithOptions(ctx context.Context, name string, args []string, opt RunOptions) (*Result, error) {
+	if strings.TrimSpace(name) == "" {
+		return nil, errors.New("command name cannot be empty")
+	}
+
+	timeout := opt.Timeout
+	if timeout <= 0 {
+		timeout = r.cfg.DefaultTimeout
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, name, args...)
+	cmd.Dir = opt.Dir
+	cmd.Env = mergeEnv(os.Environ(), opt.Env)
+	cmd.Stdin = opt.Stdin
+
+	var stdoutBuf bytes.Buffer
+	var stderrBuf bytes.Buffer
+
+	stdoutW := io.Writer(&stdoutBuf)
+	stderrW := io.Writer(&stderrBuf)
+
+	if opt.Stdout != nil {
+		stdoutW = io.MultiWriter(stdoutW, opt.Stdout)
+	} else if r.cfg.StreamOutput && !opt.Quiet {
+		stdoutW = io.MultiWriter(stdoutW, os.Stdout)
+	}
+
+	if opt.Stderr != nil {
+		stderrW = io.MultiWriter(stderrW, opt.Stderr)
+	} else if r.cfg.StreamOutput && !opt.Quiet {
+		stderrW = io.MultiWriter(stderrW, os.Stderr)
+	}
+
+	cmd.Stdout = stdoutW
+	cmd.Stderr = stderrW
+
+	start := time.Now()
+	if r.cfg.Logger != nil {
+		r.cfg.Logger.Printf("run: %s", formatCmd(name, args))
+	}
+
+	err := cmd.Run()
+	end := time.Now()
+
+	res := &Result{
+		Name:      name,
+		Args:      append([]string(nil), args...),
+		Stdout:    stdoutBuf.String(),
+		Stderr:    stderrBuf.String(),
+		Duration:  end.Sub(start),
+		StartTime: start,
+		EndTime:   end,
+		ExitCode:  exitCode(err),
+	}
+
+	if err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return res, fmt.Errorf("command timed out after %s: %s", timeout, formatCmd(name, args))
+		}
+		return res, fmt.Errorf("command failed (exit=%d): %s\nstderr:\n%s",
+			res.ExitCode, formatCmd(name, args), trimBlock(res.Stderr))
+	}
+
+	return res, nil
+}
+
+func (r *Runner) RunRetry(ctx context.Context, retry RetryOptions, name string, args ...string) (*Result, error) {
+	return r.RunRetryWithOptions(ctx, retry, name, args, RunOptions{})
+}
+
+func (r *Runner) RunRetryWithOptions(ctx context.Context, retry RetryOptions, name string, args []string, opt RunOptions) (*Result, error) {
+	if retry.Attempts <= 0 {
+		retry.Attempts = 1
+	}
+	if retry.Delay < 0 {
+		retry.Delay = 0
+	}
+
+	var lastRes *Result
+	var lastErr error
+
+	for attempt := 1; attempt <= retry.Attempts; attempt++ {
+		res, err := r.RunWithOptions(ctx, name, args, opt)
+		lastRes, lastErr = res, err
+		if err == nil {
+			return res, nil
+		}
+
+		if r.cfg.Logger != nil {
+			r.cfg.Logger.Printf("attempt %d/%d failed: %v", attempt, retry.Attempts, err)
+		}
+
+		if attempt == retry.Attempts {
+			break
+		}
+
+		select {
+		case <-ctx.Done():
+			return lastRes, ctx.Err()
+		case <-time.After(retry.Delay):
+		}
+	}
+
+	return lastRes, lastErr
+}
+
+type StepFunc func(ctx context.Context, r *Runner) error
+
+type Step struct {
+	Name        string
+	Description string
+	Retry       RetryOptions
+	Run         StepFunc
+}
+
+type StepEvent struct {
+	Name      string
+	StartTime time.Time
+	EndTime   time.Time
+	Duration  time.Duration
+	Err       error
+}
+
+type StepReporter interface {
+	StepStarted(event StepEvent)
+	StepFinished(event StepEvent)
+}
+
+type Phase struct {
+	Name  string
+	Steps []Step
+}
+
+func (r *Runner) RunPhase(ctx context.Context, phase Phase, reporter StepReporter) error {
+	for _, step := range phase.Steps {
+		start := time.Now()
+		if reporter != nil {
+			reporter.StepStarted(StepEvent{
+				Name:      step.Name,
+				StartTime: start,
+			})
+		}
+
+		var err error
+		if step.Retry.Attempts > 0 {
+			err = runStepWithRetry(ctx, r, step)
+		} else {
+			err = step.Run(ctx, r)
+		}
+
+		end := time.Now()
+		if reporter != nil {
+			reporter.StepFinished(StepEvent{
+				Name:      step.Name,
+				StartTime: start,
+				EndTime:   end,
+				Duration:  end.Sub(start),
+				Err:       err,
+			})
+		}
+
+		if err != nil {
+			return fmt.Errorf("phase %q step %q failed: %w", phase.Name, step.Name, err)
+		}
+	}
+	return nil
+}
+
+func runStepWithRetry(ctx context.Context, r *Runner, step Step) error {
+	attempts := step.Retry.Attempts
+	if attempts <= 0 {
+		attempts = 1
+	}
+
+	var lastErr error
+	for i := 1; i <= attempts; i++ {
+		lastErr = step.Run(ctx, r)
+		if lastErr == nil {
+			return nil
+		}
+		if i == attempts {
+			break
+		}
+		if r.cfg.Logger != nil {
+			r.cfg.Logger.Printf("step %q attempt %d/%d failed: %v", step.Name, i, attempts, lastErr)
+		}
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(step.Retry.Delay):
+		}
+	}
+	return lastErr
+}
+
+func CheckCommandExists(name string) error {
+	_, err := exec.LookPath(name)
+	if err != nil {
+		return fmt.Errorf("required command not found in PATH: %s", name)
+	}
+	return nil
+}
+
+func mergeEnv(base []string, extra []string) []string {
+	if len(extra) == 0 {
+		return base
+	}
+	m := map[string]string{}
+	for _, kv := range base {
+		k, v, ok := strings.Cut(kv, "=")
+		if ok {
+			m[k] = v
+		}
+	}
+	for _, kv := range extra {
+		k, v, ok := strings.Cut(kv, "=")
+		if ok {
+			m[k] = v
+		}
+	}
+	out := make([]string, 0, len(m))
+	for k, v := range m {
+		out = append(out, k+"="+v)
+	}
+	return out
+}
+
+func formatCmd(name string, args []string) string {
+	parts := make([]string, 0, len(args)+1)
+	parts = append(parts, shellQuote(name))
+	for _, a := range args {
+		parts = append(parts, shellQuote(a))
+	}
+	return strings.Join(parts, " ")
+}
+
+func shellQuote(s string) string {
+	if s == "" {
+		return "''"
+	}
+	if !strings.ContainsAny(s, " \t\n'\"\\$`!&|;<>()[]{}*?~") {
+		return s
+	}
+	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
+func trimBlock(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return "(empty)"
+	}
+	return s
+}
+
+func exitCode(err error) int {
+	if err == nil {
+		return 0
+	}
+
+	var exitErr *exec.ExitError
+	if errors.As(err, &exitErr) {
+		return exitErr.ExitCode()
+	}
+
+	return -1
+}
+
+type StdLogger struct {
+	mu sync.Mutex
+}
+
+func (l *StdLogger) Printf(format string, args ...any) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
diff --git a/clitools/pkg/templates/templates.go b/clitools/pkg/templates/templates.go
new file mode 100644
index 0000000..a1708f2
--- /dev/null
+++ b/clitools/pkg/templates/templates.go
@@ -0,0 +1,54 @@
+package templates
+
+const MonoKSConfigYAML = `apiVersion: monok8s.io/v1alpha1
+kind: MonoKSConfig
+metadata:
+  name: example
+  namespace: kube-system
+spec:
+  kubernetesVersion: v1.35.3
+  nodeName: monok8s-master-1
+  clusterName: monok8s
+  clusterDomain: cluster.local
+  podSubnet: 10.244.0.0/16
+  serviceSubnet: 10.96.0.0/12
+  apiServerAdvertiseAddress: 10.0.0.10
+  apiServerEndpoint: 10.0.0.10:6443
+  containerRuntimeEndpoint: unix:///var/run/crio/crio.sock
+  bootstrapMode: init
+  joinKind: worker
+  cniPlugin: none
+  allowSchedulingOnControlPlane: true
+  skipImageCheck: false
+  kubeProxyNodePortAddresses:
+    - primary
+  subjectAltNames:
+    - 10.0.0.10
+  nodeLabels:
+    node-role.kubernetes.io/control-plane: ""
+  nodeAnnotations: {}
+  network:
+    hostname: monok8s-master-1
+    managementIface: eth0
+    managementCIDR: 10.0.0.10/24
+    managementGateway: 10.0.0.1
+    dnsNameservers:
+      - 1.1.1.1
+      - 8.8.8.8
+    dnsSearchDomains:
+      - lan
+`
+
+const OSUpgradeYAML = `apiVersion: monok8s.io/v1alpha1
+kind: OSUpgrade
+metadata:
+  name: example
+  namespace: kube-system
+spec:
+  version: v0.0.1
+  imageURL: https://example.invalid/images/monok8s-v0.0.1.img.zst
+  targetPartition: B
+  nodeSelector:
+    - monok8s-master-1
+  force: false
+`