penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 0aa4065c264719c5cc3cff547bac43e6ee52342305236cb2126f053bbf0e541b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-03-30T09:40:27Z
subjectAdded join step
commit 0aa4065c264719c5cc3cff547bac43e6ee52342305236cb2126f053bbf0e541b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-03-30T09:40:27Z

    Added join step
---
 clitools/README                     |  33 ++++
 clitools/pkg/bootstrap/registry.go  |   2 +-
 clitools/pkg/bootstrap/runner.go    |  15 +-
 clitools/pkg/node/context.go        |  14 +-
 clitools/pkg/node/kubeadm.go        | 366 ++++++++++++++++++++++--------------
 clitools/pkg/node/kubeadm_types.go  |  81 ++++++++
 clitools/pkg/templates/templates.go |   4 +-
 7 files changed, 363 insertions(+), 152 deletions(-)

diff --git a/clitools/README b/clitools/README
index 5e1e863..6928473 100644
--- a/clitools/README
+++ b/clitools/README
@@ -26,3 +26,36 @@ And use it like this
 ```bash
 ./send.sh start_crio
 ```
+
+
+Create join token from control plane
+```
+kubeadm token create --print-join-command
+```
+
+Export the token inside the device
+```
+export DEBUG=1
+export HOSTNAME=monok8s-master-1
+export BOOTSTRAP_TOKEN=
+export TOKEN_CACERT_HASH=
+```
+
+Generate using kubectl
+```
+TOKEN_NAME=bootstrap-token-iwotl0
+
+API_SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' | sed 's|https://||') && \
+TOKEN=$(kubectl -n kube-system get secret ${TOKEN_NAME} -o jsonpath='{.data.token-id}' | base64 -d).$(kubectl -n kube-system get secret ${TOKEN_NAME} -o jsonpath='{.data.token-secret}' | base64 -d) && \
+HASH=$(kubectl -n kube-public get configmap cluster-info -o jsonpath='{.data.kubeconfig}' \
+| grep 'certificate-authority-data' \
+| awk '{print $2}' \
+| base64 -d \
+| openssl x509 -pubkey -noout \
+| openssl rsa -pubin -outform der 2>/dev/null \
+| openssl dgst -sha256 -hex \
+| awk '{print "sha256:" $2}')
+echo "export API_SERVER_ENDPOINT=${API_SERVER}"
+echo "export BOOTSTRAP_TOKEN=${TOKEN}"
+echo "export TOKEN_CACERT_HASH=${HASH}"
+```
diff --git a/clitools/pkg/bootstrap/registry.go b/clitools/pkg/bootstrap/registry.go
index 560b17e..ff84686 100644
--- a/clitools/pkg/bootstrap/registry.go
+++ b/clitools/pkg/bootstrap/registry.go
@@ -24,6 +24,7 @@ func NewRegistry(ctx *node.NodeContext) *Registry {
 		steps: map[string]node.Step{
 			"AllowSingleNodeScheduling":              node.AllowSingleNodeScheduling,
 			"ApplyLocalNodeMetadataIfPossible":       node.ApplyLocalNodeMetadataIfPossible,
+			"CheckForVersionSkew":                    node.CheckForVersionSkew,
 			"CheckUpgradePrereqs":                    node.CheckUpgradePrereqs,
 			"ClassifyBootstrapAction":                node.ClassifyBootstrapAction,
 			"ConfigureDNS":                           node.ConfigureDNS(netCfg),
@@ -32,7 +33,6 @@ func NewRegistry(ctx *node.NodeContext) *Registry {
 			"ConfigureMgmtInterface":                 node.ConfigureMgmtInterface(netCfg),
 			"DetectLocalClusterState":                node.DetectLocalClusterState,
 			"EnsureIPForward":                        node.EnsureIPForward,
-			"GenerateKubeadmConfig":                  node.GenerateKubeadmConfig,
 			"PrintSummary":                           node.PrintSummary,
 			"ReconcileControlPlane":                  node.ReconcileControlPlane,
 			"ReconcileNode":                          node.ReconcileNode,
diff --git a/clitools/pkg/bootstrap/runner.go b/clitools/pkg/bootstrap/runner.go
index ae13886..9f3d0c7 100644
--- a/clitools/pkg/bootstrap/runner.go
+++ b/clitools/pkg/bootstrap/runner.go
@@ -86,6 +86,16 @@ func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
 				Name:   "Classify bootstrap action",
 				Desc:   "Decide whether to init, join, upgrade, or reconcile based on local state and desired version",
 			},
+			{
+				RegKey: "CheckForVersionSkew",
+				Name:   "Check for version skew",
+				Desc:   "Validate wether version satisfy the requirements againts current cluster if any",
+			},
+			{
+				RegKey: "RunKubeadmInit",
+				Name:   "Run kubeadm init",
+				Desc:   "Initialize a new Kubernetes control plane using kubeadm",
+			},
 			{
 				RegKey: "WaitForExistingClusterIfNeeded",
 				Name:   "Wait for existing cluster",
@@ -106,11 +116,6 @@ func NewRunner(cfg *monov1alpha1.MonoKSConfig) *Runner {
 				Name:   "Run kubeadm upgrade apply",
 				Desc:   "Upgrade control plane components using kubeadm",
 			},
-			{
-				RegKey: "RunKubeadmInit",
-				Name:   "Run kubeadm init",
-				Desc:   "Initialize a new Kubernetes control plane using kubeadm",
-			},
 			{
 				RegKey: "RunKubeadmJoin",
 				Name:   "Run kubeadm join",
diff --git a/clitools/pkg/node/context.go b/clitools/pkg/node/context.go
index ba01c63..0b523d0 100644
--- a/clitools/pkg/node/context.go
+++ b/clitools/pkg/node/context.go
@@ -34,13 +34,17 @@ type LocalClusterState struct {
 type BootstrapAction string
 
 const (
-	BootstrapActionInitControlPlane      BootstrapAction = "init-control-plane"
-	BootstrapActionJoinWorker            BootstrapAction = "join-worker"
-	BootstrapActionJoinControlPlane      BootstrapAction = "join-control-plane"
-	BootstrapActionReconcileWorker       BootstrapAction = "reconcile-worker"
+	BootstrapActionInitControlPlane   BootstrapAction = "init-control-plane"
+	BootstrapActionJoinControlPlane   BootstrapAction = "join-control-plane"
+	BootstrapActionManageControlPlane BootstrapAction = "manage-control-plane"
+
+	BootstrapActionJoinWorker   BootstrapAction = "join-worker"
+	BootstrapActionManageWorker BootstrapAction = "manage-worker"
+
 	BootstrapActionReconcileControlPlane BootstrapAction = "reconcile-control-plane"
-	BootstrapActionUpgradeWorker         BootstrapAction = "upgrade-worker"
 	BootstrapActionUpgradeControlPlane   BootstrapAction = "upgrade-control-plane"
+	BootstrapActionReconcileWorker       BootstrapAction = "reconcile-worker"
+	BootstrapActionUpgradeWorker         BootstrapAction = "upgrade-worker"
 )
 
 type BootstrapState struct {
diff --git a/clitools/pkg/node/kubeadm.go b/clitools/pkg/node/kubeadm.go
index f3fb5f7..fd943ce 100644
--- a/clitools/pkg/node/kubeadm.go
+++ b/clitools/pkg/node/kubeadm.go
@@ -17,6 +17,7 @@ import (
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/klog/v2"
 
+	types "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
 	system "undecided.project/monok8s/pkg/system"
 )
 
@@ -114,6 +115,10 @@ func WaitForExistingClusterIfNeeded(ctx context.Context, nctx *NodeContext) erro
 }
 
 func CheckForVersionSkew(ctx context.Context, nctx *NodeContext) error {
+	if nctx.BootstrapState == nil {
+		return errors.New("BootstrapState is nil, call ClassifyBootstrapAction() first")
+	}
+
 	role := strings.TrimSpace(nctx.Config.Spec.ClusterRole)
 	wantVersion := normalizeKubeVersion(strings.TrimSpace(nctx.Config.Spec.KubernetesVersion))
 	if wantVersion == "" {
@@ -122,7 +127,7 @@ func CheckForVersionSkew(ctx context.Context, nctx *NodeContext) error {
 
 	switch nctx.LocalClusterState.MembershipKind {
 	case LocalMembershipFresh:
-		// Fresh node has no existing cluster membership to compare against.
+		// Nothing to compare for fresh nodes.
 		return nil
 	case LocalMembershipPartial:
 		return fmt.Errorf("cannot check version skew with partial local cluster state")
@@ -142,6 +147,10 @@ func CheckForVersionSkew(ctx context.Context, nctx *NodeContext) error {
 		// Worker path stays permissive.
 		nctx.BootstrapState.UnsupportedWorkerVersionSkew = true
 		nctx.BootstrapState.VersionSkewReason = "cluster version could not be determined"
+
+		if nctx.BootstrapState.Action == BootstrapActionManageWorker {
+			nctx.BootstrapState.Action = BootstrapActionReconcileWorker
+		}
 		return nil
 	}
 
@@ -156,6 +165,14 @@ func CheckForVersionSkew(ctx context.Context, nctx *NodeContext) error {
 			)
 		}
 
+		if nctx.BootstrapState.Action == BootstrapActionManageControlPlane {
+			if versionEq(currentVersion, wantVersion) {
+				nctx.BootstrapState.Action = BootstrapActionReconcileControlPlane
+			} else {
+				nctx.BootstrapState.Action = BootstrapActionUpgradeControlPlane
+			}
+		}
+
 	case "worker":
 		if !isSupportedWorkerSkew(currentVersion, wantVersion) {
 			nctx.BootstrapState.UnsupportedWorkerVersionSkew = true
@@ -165,6 +182,14 @@ func CheckForVersionSkew(ctx context.Context, nctx *NodeContext) error {
 			)
 		}
 
+		if nctx.BootstrapState.Action == BootstrapActionManageWorker {
+			if versionEq(currentVersion, wantVersion) {
+				nctx.BootstrapState.Action = BootstrapActionReconcileWorker
+			} else {
+				nctx.BootstrapState.Action = BootstrapActionUpgradeWorker
+			}
+		}
+
 	default:
 		return fmt.Errorf("unsupported cluster role %q", role)
 	}
@@ -187,10 +212,8 @@ func ClassifyBootstrapAction(ctx context.Context, nctx *NodeContext) error {
 	}
 
 	state := &BootstrapState{}
-
-	// Preserve already-detected info if earlier steps populated it.
 	if nctx.BootstrapState != nil {
-		state.DetectedClusterVersion = nctx.BootstrapState.DetectedClusterVersion
+		*state = *nctx.BootstrapState
 	}
 
 	switch role {
@@ -200,13 +223,7 @@ func ClassifyBootstrapAction(ctx context.Context, nctx *NodeContext) error {
 			state.Action = BootstrapActionJoinWorker
 
 		case LocalMembershipExistingWorker:
-			if state.DetectedClusterVersion == "" {
-				state.Action = BootstrapActionReconcileWorker
-			} else if versionEq(state.DetectedClusterVersion, wantVersion) {
-				state.Action = BootstrapActionReconcileWorker
-			} else {
-				state.Action = BootstrapActionUpgradeWorker
-			}
+			state.Action = BootstrapActionManageWorker
 
 		case LocalMembershipExistingControlPlane, LocalMembershipPartial:
 			return fmt.Errorf("local state %q is invalid for worker role", nctx.LocalClusterState.MembershipKind)
@@ -225,15 +242,7 @@ func ClassifyBootstrapAction(ctx context.Context, nctx *NodeContext) error {
 			}
 
 		case LocalMembershipExistingControlPlane:
-			if state.DetectedClusterVersion == "" {
-				return errors.New("existing control-plane state found, but detected cluster version is empty")
-			}
-
-			if versionEq(state.DetectedClusterVersion, wantVersion) {
-				state.Action = BootstrapActionReconcileControlPlane
-			} else {
-				state.Action = BootstrapActionUpgradeControlPlane
-			}
+			state.Action = BootstrapActionManageControlPlane
 
 		case LocalMembershipExistingWorker:
 			return fmt.Errorf("local state %q is invalid for control-plane role", nctx.LocalClusterState.MembershipKind)
@@ -250,7 +259,7 @@ func ClassifyBootstrapAction(ctx context.Context, nctx *NodeContext) error {
 	}
 
 	nctx.BootstrapState = state
-	klog.V(4).Infof("Bootstrap action: %+v", *state)
+	klog.V(4).Infof("Bootstrap action classified: %+v", *state)
 	return nil
 }
 
@@ -521,7 +530,10 @@ func checkImagePresent(ctx context.Context, n *NodeContext, image string) error
 	}
 
 	// crictl inspecti exits non-zero when the image is absent.
-	_, err := n.SystemRunner.Run(ctx, "crictl", "inspecti", image)
+	_, err := n.SystemRunner.RunRetry(ctx, system.RetryOptions{
+		Attempts: 3,
+		Delay:    1 * system.DefaultSecond,
+	}, "crictl", "inspecti", image)
 	if err != nil {
 		return fmt.Errorf("image %q not present: %w", image, err)
 	}
@@ -553,7 +565,43 @@ func normalizeKubeVersion(v string) string {
 	return v
 }
 
-func GenerateKubeadmConfig(_ context.Context, nctx *NodeContext) error {
+func buildNodeRegistration(spec types.MonoKSConfigSpec) NodeRegistrationOptions {
+	nodeName := strings.TrimSpace(spec.NodeName)
+	criSocket := strings.TrimSpace(spec.ContainerRuntimeEndpoint)
+	advertiseAddress := strings.TrimSpace(spec.APIServerAdvertiseAddress)
+
+	nr := NodeRegistrationOptions{
+		Name:            nodeName,
+		CRISocket:       criSocket,
+		ImagePullPolicy: "IfNotPresent",
+		KubeletExtraArgs: []KubeadmArg{
+			{Name: "hostname-override", Value: nodeName},
+			{Name: "pod-manifest-path", Value: "/etc/kubernetes/manifests"},
+		},
+	}
+
+	if advertiseAddress != "" {
+		nr.KubeletExtraArgs = append(nr.KubeletExtraArgs,
+			KubeadmArg{Name: "node-ip", Value: advertiseAddress},
+		)
+	}
+
+	return nr
+}
+
+func maybeAddBootstrapTaint(nr *NodeRegistrationOptions, role string) {
+	if strings.TrimSpace(role) != "worker" {
+		return
+	}
+	nr.Taints = []KubeadmTaint{
+		{
+			Key:    "monok8s/bootstrap",
+			Effect: "NoSchedule",
+		},
+	}
+}
+
+func GenerateKubeadmInitConfig(_ context.Context, nctx *NodeContext) error {
 	if nctx == nil {
 		return fmt.Errorf("node context is nil")
 	}
@@ -564,47 +612,29 @@ func GenerateKubeadmConfig(_ context.Context, nctx *NodeContext) error {
 	if advertiseAddress == "" {
 		return fmt.Errorf("api server advertise address is required")
 	}
-
-	nodeName := strings.TrimSpace(spec.NodeName)
-	if nodeName == "" {
-		return fmt.Errorf("node name is required")
-	}
-
-	criSocket := strings.TrimSpace(spec.ContainerRuntimeEndpoint)
-	if criSocket == "" {
-		return fmt.Errorf("container runtime endpoint is required")
-	}
-
 	clusterName := strings.TrimSpace(spec.ClusterName)
 	if clusterName == "" {
 		return fmt.Errorf("cluster name is required")
 	}
-
 	kubernetesVersion := strings.TrimSpace(spec.KubernetesVersion)
 	if kubernetesVersion == "" {
 		return fmt.Errorf("kubernetes version is required")
 	}
-
 	podSubnet := strings.TrimSpace(spec.PodSubnet)
 	if podSubnet == "" {
 		return fmt.Errorf("pod subnet is required")
 	}
-
 	serviceSubnet := strings.TrimSpace(spec.ServiceSubnet)
 	if serviceSubnet == "" {
 		return fmt.Errorf("service subnet is required")
 	}
-
 	clusterDomain := strings.TrimSpace(spec.ClusterDomain)
 	if clusterDomain == "" {
 		return fmt.Errorf("cluster domain is required")
 	}
 
 	certSANs := []string{advertiseAddress}
-	seen := map[string]struct{}{
-		advertiseAddress: {},
-	}
-
+	seen := map[string]struct{}{advertiseAddress: {}}
 	for _, raw := range spec.SubjectAltNames {
 		san := strings.TrimSpace(raw)
 		if san == "" {
@@ -617,94 +647,129 @@ func GenerateKubeadmConfig(_ context.Context, nctx *NodeContext) error {
 		certSANs = append(certSANs, san)
 	}
 
-	type kubeadmInitConfiguration struct {
-		APIVersion       string `yaml:"apiVersion"`
-		Kind             string `yaml:"kind"`
-		LocalAPIEndpoint struct {
-			AdvertiseAddress string `yaml:"advertiseAddress"`
-			BindPort         int    `yaml:"bindPort"`
-		} `yaml:"localAPIEndpoint"`
-		NodeRegistration struct {
-			Name             string `yaml:"name"`
-			CRISocket        string `yaml:"criSocket"`
-			ImagePullPolicy  string `yaml:"imagePullPolicy"`
-			KubeletExtraArgs []struct {
-				Name  string `yaml:"name"`
-				Value string `yaml:"value"`
-			} `yaml:"kubeletExtraArgs"`
-		} `yaml:"nodeRegistration"`
-	}
-
-	type kubeadmClusterConfiguration struct {
-		APIVersion        string `yaml:"apiVersion"`
-		Kind              string `yaml:"kind"`
-		ClusterName       string `yaml:"clusterName"`
-		KubernetesVersion string `yaml:"kubernetesVersion"`
-		Networking        struct {
-			PodSubnet     string `yaml:"podSubnet"`
-			ServiceSubnet string `yaml:"serviceSubnet"`
-			DNSDomain     string `yaml:"dnsDomain"`
-		} `yaml:"networking"`
-		APIServer struct {
-			CertSANs []string `yaml:"certSANs"`
-		} `yaml:"apiServer"`
-	}
-
-	type kubeletConfiguration struct {
-		APIVersion               string `yaml:"apiVersion"`
-		Kind                     string `yaml:"kind"`
-		CgroupDriver             string `yaml:"cgroupDriver"`
-		ContainerRuntimeEndpoint string `yaml:"containerRuntimeEndpoint"`
-	}
-
-	initCfg := kubeadmInitConfiguration{
+	nodeReg := buildNodeRegistration(spec)
+	if spec.ClusterRole == "worker" {
+		maybeAddBootstrapTaint(&nodeReg, spec.ClusterRole)
+	}
+
+	initCfg := InitConfiguration{
 		APIVersion: "kubeadm.k8s.io/v1beta4",
 		Kind:       "InitConfiguration",
+		LocalAPIEndpoint: LocalAPIEndpoint{
+			AdvertiseAddress: advertiseAddress,
+			BindPort:         6443,
+		},
+		NodeRegistration: nodeReg,
 	}
-	initCfg.LocalAPIEndpoint.AdvertiseAddress = advertiseAddress
-	initCfg.LocalAPIEndpoint.BindPort = 6443
-	initCfg.NodeRegistration.Name = nodeName
-	initCfg.NodeRegistration.CRISocket = criSocket
-	initCfg.NodeRegistration.ImagePullPolicy = "IfNotPresent"
-	initCfg.NodeRegistration.KubeletExtraArgs = []struct {
-		Name  string `yaml:"name"`
-		Value string `yaml:"value"`
-	}{
-		{Name: "hostname-override", Value: nodeName},
-		{Name: "node-ip", Value: advertiseAddress},
-		{Name: "pod-manifest-path", Value: "/etc/kubernetes/manifests"},
-	}
-
-	clusterCfg := kubeadmClusterConfiguration{
+
+	clusterCfg := ClusterConfiguration{
 		APIVersion:        "kubeadm.k8s.io/v1beta4",
 		Kind:              "ClusterConfiguration",
 		ClusterName:       clusterName,
 		KubernetesVersion: kubernetesVersion,
+		Networking: Networking{
+			PodSubnet:     podSubnet,
+			ServiceSubnet: serviceSubnet,
+			DNSDomain:     clusterDomain,
+		},
+		APIServer: APIServer{
+			CertSANs: certSANs,
+		},
 	}
-	clusterCfg.Networking.PodSubnet = podSubnet
-	clusterCfg.Networking.ServiceSubnet = serviceSubnet
-	clusterCfg.Networking.DNSDomain = clusterDomain
-	clusterCfg.APIServer.CertSANs = certSANs
 
-	kubeletCfg := kubeletConfiguration{
+	kubeletCfg := KubeletConfiguration{
 		APIVersion:               "kubelet.config.k8s.io/v1beta1",
 		Kind:                     "KubeletConfiguration",
 		CgroupDriver:             "cgroupfs",
-		ContainerRuntimeEndpoint: criSocket,
+		ContainerRuntimeEndpoint: strings.TrimSpace(spec.ContainerRuntimeEndpoint),
 	}
 
-	var docs [][]byte
+	return writeKubeadmYAML(tmpKubeadmInitConf, initCfg, clusterCfg, kubeletCfg)
+}
 
-	for _, doc := range []any{initCfg, clusterCfg, kubeletCfg} {
+func GenerateKubeadmJoinConfig(_ context.Context, nctx *NodeContext) error {
+	if nctx == nil {
+		return fmt.Errorf("node context is nil")
+	}
+
+	spec := nctx.Config.Spec
+
+	apiServerEndpoint := strings.TrimSpace(spec.APIServerEndpoint)
+	if apiServerEndpoint == "" {
+		return fmt.Errorf("spec.apiServerEndpoint is required")
+	}
+
+	bootstrapToken := strings.TrimSpace(spec.BootstrapToken)
+	if bootstrapToken == "" {
+		return fmt.Errorf("spec.bootstrapToken is required")
+	}
+
+	discoveryTokenCACertHash := strings.TrimSpace(spec.DiscoveryTokenCACertHash)
+	if discoveryTokenCACertHash == "" {
+		return fmt.Errorf("spec.discoveryTokenCACertHash is required")
+	}
+
+	nodeReg := buildNodeRegistration(spec)
+	if nctx.BootstrapState != nil && nctx.BootstrapState.Action == BootstrapActionJoinWorker {
+		maybeAddBootstrapTaint(&nodeReg, spec.ClusterRole)
+	}
+
+	joinCfg := JoinConfiguration{
+		APIVersion:       "kubeadm.k8s.io/v1beta4",
+		Kind:             "JoinConfiguration",
+		NodeRegistration: nodeReg,
+		Discovery: Discovery{
+			BootstrapToken: BootstrapTokenDiscovery{
+				APIServerEndpoint: apiServerEndpoint,
+				Token:             bootstrapToken,
+				CACertHashes:      []string{discoveryTokenCACertHash},
+			},
+		},
+	}
+
+	if nctx.BootstrapState != nil && nctx.BootstrapState.Action == BootstrapActionJoinControlPlane {
+		certKey := strings.TrimSpace(spec.ControlPlaneCertKey)
+		if certKey == "" {
+			return fmt.Errorf("spec.controlPlaneCertKey is required for control-plane join")
+		}
+
+		advertiseAddress := strings.TrimSpace(spec.APIServerAdvertiseAddress)
+		if advertiseAddress == "" {
+			return fmt.Errorf("spec.apiServerAdvertiseAddress is required for control-plane join")
+		}
+
+		joinCfg.ControlPlane = &JoinControlPlane{
+			CertificateKey: certKey,
+		}
+		joinCfg.LocalAPIEndpoint = &LocalAPIEndpoint{
+			AdvertiseAddress: advertiseAddress,
+			BindPort:         6443,
+		}
+	}
+
+	kubeletCfg := KubeletConfiguration{
+		APIVersion:               "kubelet.config.k8s.io/v1beta1",
+		Kind:                     "KubeletConfiguration",
+		CgroupDriver:             "cgroupfs",
+		ContainerRuntimeEndpoint: strings.TrimSpace(spec.ContainerRuntimeEndpoint),
+	}
+
+	return writeKubeadmYAML(tmpKubeadmInitConf, joinCfg, kubeletCfg)
+}
+
+func writeKubeadmYAML(path string, docs ...any) error {
+	var renderedDocs [][]byte
+
+	for _, doc := range docs {
 		b, err := yaml.Marshal(doc)
 		if err != nil {
 			return fmt.Errorf("marshal kubeadm config document: %w", err)
 		}
-		docs = append(docs, bytes.TrimSpace(b))
+		renderedDocs = append(renderedDocs, bytes.TrimSpace(b))
 	}
 
 	var buf bytes.Buffer
-	for i, doc := range docs {
+	for i, doc := range renderedDocs {
 		if i > 0 {
 			buf.WriteString("\n---\n")
 		}
@@ -714,16 +779,25 @@ func GenerateKubeadmConfig(_ context.Context, nctx *NodeContext) error {
 
 	rendered := buf.String()
 
-	if err := os.WriteFile(tmpKubeadmInitConf, []byte(rendered), 0o600); err != nil {
-		return fmt.Errorf("write kubeadm config to %s: %w", tmpKubeadmInitConf, err)
+	if err := os.WriteFile(path, []byte(rendered), 0o600); err != nil {
+		return fmt.Errorf("write kubeadm config to %s: %w", path, err)
 	}
 
-	klog.V(4).Infof("generated kubeadm config at %s:\n%s", tmpKubeadmInitConf, rendered)
+	klog.V(4).Infof("generated kubeadm config at %s:\n%s", path, rendered)
 	return nil
 }
 
 func RunKubeadmInit(ctx context.Context, nctx *NodeContext) error {
-	if err := GenerateKubeadmConfig(ctx, nctx); err != nil {
+	if nctx.BootstrapState == nil {
+		return errors.New("BootstrapState is nil. Please run earlier steps first")
+	}
+
+	if nctx.BootstrapState.Action != BootstrapActionInitControlPlane {
+		klog.V(4).Infof("skipped for %s", nctx.BootstrapState.Action)
+		return nil
+	}
+
+	if err := GenerateKubeadmInitConfig(ctx, nctx); err != nil {
 		return err
 	}
 
@@ -740,39 +814,51 @@ func RunKubeadmInit(ctx context.Context, nctx *NodeContext) error {
 	return err
 }
 
-func RunKubeadmUpgradeApply(context.Context, *NodeContext) error {
-	klog.Info("run_kubeadm_upgrade_apply: TODO implement kubeadm upgrade apply")
+func RunKubeadmJoin(ctx context.Context, nctx *NodeContext) error {
+	if nctx.BootstrapState == nil {
+		return errors.New("BootstrapState is nil. Please run earlier steps first")
+	}
+
+	switch nctx.BootstrapState.Action {
+	case BootstrapActionJoinWorker, BootstrapActionJoinControlPlane:
+		// continue
+	default:
+		klog.V(4).Infof("RunKubeadmJoin skipped for action %q", nctx.BootstrapState.Action)
+		return nil
+	}
+
+	if err := GenerateKubeadmJoinConfig(ctx, nctx); err != nil {
+		return err
+	}
+
+	klog.Infof("running kubeadm join for action %q", nctx.BootstrapState.Action)
+
+	_, err := nctx.SystemRunner.RunWithOptions(
+		ctx,
+		"kubeadm",
+		[]string{"join", "--config", tmpKubeadmInitConf},
+		system.RunOptions{
+			Timeout: 5 * time.Minute,
+			OnStdoutLine: func(line string) {
+				klog.Infof("[kubeadm] %s", line)
+			},
+			OnStderrLine: func(line string) {
+				klog.Infof("[kubeadm] %s", line)
+			},
+		},
+	)
+	if err != nil {
+		return fmt.Errorf("run kubeadm join: %w", err)
+	}
+
 	return nil
 }
 
-func RunKubeadmJoin(context.Context, *NodeContext) error {
-	/*
-		run_kubeadm_join() {
-		    log "running kubeadm join..."
-
-		    case "$JOIN_KIND" in
-		        worker)
-		            kubeadm join "${API_SERVER_ENDPOINT}" \
-		                --token "${BOOTSTRAP_TOKEN}" \
-		                --discovery-token-ca-cert-hash "${DISCOVERY_TOKEN_CA_CERT_HASH}" \
-		                --node-name "${NODE_NAME}" \
-		                --cri-socket "${CONTAINER_RUNTIME_ENDPOINT}"
-		            ;;
-		        control-plane)
-		            kubeadm join "${API_SERVER_ENDPOINT}" \
-		                --token "${BOOTSTRAP_TOKEN}" \
-		                --discovery-token-ca-cert-hash "${DISCOVERY_TOKEN_CA_CERT_HASH}" \
-		                --control-plane \
-		                --certificate-key "${CONTROL_PLANE_CERT_KEY}" \
-		                --apiserver-advertise-address "${APISERVER_ADVERTISE_ADDRESS}" \
-		                --node-name "${NODE_NAME}" \
-		                --cri-socket "${CONTAINER_RUNTIME_ENDPOINT}"
-		            ;;
-		    esac
-		}
-	*/
+func RunKubeadmUpgradeApply(context.Context, *NodeContext) error {
+	klog.Info("run_kubeadm_upgrade_apply: TODO implement kubeadm upgrade apply")
 	return nil
 }
+
 func RunKubeadmUpgradeNode(context.Context, *NodeContext) error {
 	klog.Info("run_kubeadm_upgrade_node: TODO implement kubeadm upgrade node")
 	return nil
diff --git a/clitools/pkg/node/kubeadm_types.go b/clitools/pkg/node/kubeadm_types.go
new file mode 100644
index 0000000..f9f7e61
--- /dev/null
+++ b/clitools/pkg/node/kubeadm_types.go
@@ -0,0 +1,81 @@
+package node
+
+type KubeadmArg struct {
+	Name  string `yaml:"name"`
+	Value string `yaml:"value"`
+}
+
+type KubeadmTaint struct {
+	Key    string `yaml:"key"`
+	Value  string `yaml:"value,omitempty"`
+	Effect string `yaml:"effect"`
+}
+
+type LocalAPIEndpoint struct {
+	AdvertiseAddress string `yaml:"advertiseAddress"`
+	BindPort         int    `yaml:"bindPort"`
+}
+
+type NodeRegistrationOptions struct {
+	Name             string         `yaml:"name"`
+	CRISocket        string         `yaml:"criSocket"`
+	ImagePullPolicy  string         `yaml:"imagePullPolicy,omitempty"`
+	Taints           []KubeadmTaint `yaml:"taints,omitempty"`
+	KubeletExtraArgs []KubeadmArg   `yaml:"kubeletExtraArgs,omitempty"`
+}
+
+type Networking struct {
+	PodSubnet     string `yaml:"podSubnet,omitempty"`
+	ServiceSubnet string `yaml:"serviceSubnet,omitempty"`
+	DNSDomain     string `yaml:"dnsDomain,omitempty"`
+}
+
+type APIServer struct {
+	CertSANs []string `yaml:"certSANs,omitempty"`
+}
+
+type InitConfiguration struct {
+	APIVersion       string                  `yaml:"apiVersion"`
+	Kind             string                  `yaml:"kind"`
+	LocalAPIEndpoint LocalAPIEndpoint        `yaml:"localAPIEndpoint"`
+	NodeRegistration NodeRegistrationOptions `yaml:"nodeRegistration"`
+}
+
+type ClusterConfiguration struct {
+	APIVersion        string     `yaml:"apiVersion"`
+	Kind              string     `yaml:"kind"`
+	ClusterName       string     `yaml:"clusterName"`
+	KubernetesVersion string     `yaml:"kubernetesVersion"`
+	Networking        Networking `yaml:"networking"`
+	APIServer         APIServer  `yaml:"apiServer"`
+}
+
+type BootstrapTokenDiscovery struct {
+	APIServerEndpoint string   `yaml:"apiServerEndpoint"`
+	Token             string   `yaml:"token"`
+	CACertHashes      []string `yaml:"caCertHashes"`
+}
+
+type Discovery struct {
+	BootstrapToken BootstrapTokenDiscovery `yaml:"bootstrapToken"`
+}
+
+type JoinControlPlane struct {
+	CertificateKey string `yaml:"certificateKey"`
+}
+
+type JoinConfiguration struct {
+	APIVersion       string                  `yaml:"apiVersion"`
+	Kind             string                  `yaml:"kind"`
+	NodeRegistration NodeRegistrationOptions `yaml:"nodeRegistration"`
+	Discovery        Discovery               `yaml:"discovery"`
+	ControlPlane     *JoinControlPlane       `yaml:"controlPlane,omitempty"`
+	LocalAPIEndpoint *LocalAPIEndpoint       `yaml:"localAPIEndpoint,omitempty"`
+}
+
+type KubeletConfiguration struct {
+	APIVersion               string `yaml:"apiVersion"`
+	Kind                     string `yaml:"kind"`
+	CgroupDriver             string `yaml:"cgroupDriver"`
+	ContainerRuntimeEndpoint string `yaml:"containerRuntimeEndpoint"`
+}
diff --git a/clitools/pkg/templates/templates.go b/clitools/pkg/templates/templates.go
index 93af46e..e68a96e 100644
--- a/clitools/pkg/templates/templates.go
+++ b/clitools/pkg/templates/templates.go
@@ -8,6 +8,7 @@ import (
 	buildinfo "undecided.project/monok8s/pkg/buildinfo"
 )
 
+var ValAPIServerEndPoint string = "10.0.0.10:6443"
 var ValHostname string = "monoks-master-1"
 var ValBootstrapToken string = "abcd12.ef3456789abcdef0"
 var ValDiscoveryTokenCACertHash string = "sha256:9f1c2b3a4d5e6f7890abc1234567890abcdef1234567890abcdef1234567890ab"
@@ -16,6 +17,7 @@ func init() {
 	ValBootstrapToken = os.Getenv("HOSTNAME")
 	ValBootstrapToken = os.Getenv("BOOTSTRAP_TOKEN")
 	ValDiscoveryTokenCACertHash = os.Getenv("TOKEN_CACERT_HASH")
+	ValAPIServerEndPoint = os.Getenv("API_SERVER_ENDPOINT")
 }
 
 func DefaultMonoKSConfig() types.MonoKSConfig {
@@ -42,7 +44,7 @@ func DefaultMonoKSConfig() types.MonoKSConfig {
 			ServiceSubnet: "10.96.0.0/12",
 
 			APIServerAdvertiseAddress: "10.0.0.10",
-			APIServerEndpoint:         "10.0.0.10:6443",
+			APIServerEndpoint:         ValAPIServerEndPoint,
 
 			// Fake token and hash for placeholder purpose
 			BootstrapToken:           ValBootstrapToken,