penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit 01ec867c2f43892fc82e9843eed3dec1de6d593b1e43415fb426b9d1a4d8b7f8

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-03-25T10:05:42Z
subjectCan now create a node worker
commit 01ec867c2f43892fc82e9843eed3dec1de6d593b1e43415fb426b9d1a4d8b7f8
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-03-25T10:05:42Z

    Can now create a node worker
---
 alpine/build-rootfs.sh                             |   2 +-
 alpine/configure-system.sh                         |   1 -
 alpine/install-packages.sh                         |   3 -
 alpine/rootfs-extra/etc/fancontrol                 |   2 +-
 .../rootfs-extra/opt/scripts/apply-node-config.sh  | 149 +++++++
 .../rootfs-extra/opt/scripts/bootstrap-cluster.sh  | 436 +++++++++++++++++++++
 .../opt/scripts/bootstrap-control-plane.sh         | 377 ------------------
 configs/cluster.env.default                        |  18 +
 configs/node.env.default                           |   7 +
 initramfs/rootfs-extra/bin/flash-emmc.sh.tmpl      | 140 +++++--
 kernel-build/dts/mono-gateway-dk.dts               |   3 +
 makefile                                           |  17 +-
 12 files changed, 747 insertions(+), 408 deletions(-)

diff --git a/alpine/build-rootfs.sh b/alpine/build-rootfs.sh
index 99933b0..8031868 100755
--- a/alpine/build-rootfs.sh
+++ b/alpine/build-rootfs.sh
@@ -51,7 +51,7 @@ rm -r "$ROOTFS/build"
 echo "##################################################### Packaging RootFS "$( du -sh "$ROOTFS/" )
 
 IMG=output.img
-SIZE=2048MB
+SIZE=3072MB
 
 dd if=/dev/zero of="$IMG" bs=1 count=0 seek=$SIZE
 
diff --git a/alpine/configure-system.sh b/alpine/configure-system.sh
index f7e8636..9d931d1 100755
--- a/alpine/configure-system.sh
+++ b/alpine/configure-system.sh
@@ -11,4 +11,3 @@ rc-update add fancontrol boot
 rc-update add loopback boot
 rc-update add hostname boot
 rc-update add localmount boot
-rc-update add crio default
diff --git a/alpine/install-packages.sh b/alpine/install-packages.sh
index 11855cb..08c8630 100755
--- a/alpine/install-packages.sh
+++ b/alpine/install-packages.sh
@@ -31,9 +31,6 @@ if [ $? -ne 0 ]; then
 	exit $?
 fi
 
-mv /etc/cni/net.d/10-crio-bridge.conflist.disabled \
-   /etc/cni/net.d/10-crio-bridge.conflist
-
 sed -i "s/default_runtime = \"crun\"/\0\ncgroup_manager = \"cgroupfs\"/g" /etc/crio/crio.conf.d/10-crio.conf
 grep cgroup_manager /etc/crio/crio.conf.d/10-crio.conf || exit 1
 
diff --git a/alpine/rootfs-extra/etc/fancontrol b/alpine/rootfs-extra/etc/fancontrol
index b378156..143d6eb 100644
--- a/alpine/rootfs-extra/etc/fancontrol
+++ b/alpine/rootfs-extra/etc/fancontrol
@@ -3,7 +3,7 @@ DEVPATH=hwmon0=devices/platform/soc/2180000.i2c/i2c-0/i2c-7/7-002e hwmon1=device
 DEVNAME=hwmon0=emc2305 hwmon1=ddr_thermal
 FCTEMPS=hwmon0/pwm2=hwmon1/temp1_input
 FCFANS= hwmon0/pwm2=hwmon0/fan1_input
-MINTEMP=hwmon0/pwm2=40
+MINTEMP=hwmon0/pwm2=35
 MAXTEMP=hwmon0/pwm2=60
 MINSTART=hwmon0/pwm2=60
 MINSTOP=hwmon0/pwm2=45
diff --git a/alpine/rootfs-extra/opt/scripts/apply-node-config.sh b/alpine/rootfs-extra/opt/scripts/apply-node-config.sh
new file mode 100644
index 0000000..74391e0
--- /dev/null
+++ b/alpine/rootfs-extra/opt/scripts/apply-node-config.sh
@@ -0,0 +1,149 @@
+#!/bin/bash
+set -euo pipefail
+
+CONFIG_DIR="${CONFIG_DIR:-/opt/monok8s/config}"
+NODE_ENV="${NODE_ENV:-$CONFIG_DIR/node.env}"
+
+log() {
+    echo "[monok8s-node] $*"
+}
+
+fail() {
+    echo "[monok8s-node] ERROR: $*" >&2
+    exit 1
+}
+
+need_cmd() {
+    command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
+}
+
+require_file() {
+    [ -f "$1" ] || fail "required file not found: $1"
+}
+
+load_config() {
+    require_file "$NODE_ENV"
+
+    # shellcheck disable=SC1090
+    . "$NODE_ENV"
+
+    HOSTNAME="${HOSTNAME:-}"
+    MGMT_IFACE="${MGMT_IFACE:-}"
+    MGMT_ADDRESS="${MGMT_ADDRESS:-}"
+    MGMT_GATEWAY="${MGMT_GATEWAY:-}"
+}
+
+validate_config() {
+    [ -n "$HOSTNAME" ] || fail "HOSTNAME is required"
+    [ -n "$MGMT_IFACE" ] || fail "MGMT_IFACE is required"
+    [ -n "$MGMT_ADDRESS" ] || fail "MGMT_ADDRESS is required"
+
+    case "$MGMT_ADDRESS" in
+        */*)
+            ;;
+        *)
+            fail "MGMT_ADDRESS must include a CIDR prefix, example: 10.0.0.13/24"
+            ;;
+    esac
+}
+
+check_prereqs() {
+    need_cmd ip
+    need_cmd hostname
+    need_cmd grep
+    need_cmd awk
+    need_cmd cut
+    need_cmd mkdir
+    need_cmd printf
+    need_cmd cat
+}
+
+configure_mgmt_interface() {
+    local addr_ip
+
+    ip link show "$MGMT_IFACE" >/dev/null 2>&1 || fail "interface not found: $MGMT_IFACE"
+
+    log "bringing up interface: $MGMT_IFACE"
+    ip link set "$MGMT_IFACE" up
+
+    addr_ip="${MGMT_ADDRESS%/*}"
+
+    if ip -o addr show dev "$MGMT_IFACE" | awk '{print $4}' | cut -d/ -f1 | grep -Fx "$addr_ip" >/dev/null 2>&1; then
+        log "address already present on $MGMT_IFACE: $MGMT_ADDRESS"
+    else
+        log "assigning $MGMT_ADDRESS to $MGMT_IFACE"
+        ip addr add "$MGMT_ADDRESS" dev "$MGMT_IFACE"
+    fi
+
+    if [ -n "${MGMT_GATEWAY:-}" ]; then
+        log "ensuring default route via $MGMT_GATEWAY"
+        ip route replace default via "$MGMT_GATEWAY" dev "$MGMT_IFACE"
+    fi
+}
+
+set_hostname_if_needed() {
+    local current_hostname
+
+    current_hostname="$(hostname 2>/dev/null || true)"
+
+    if [ "$current_hostname" != "$HOSTNAME" ]; then
+        log "setting hostname to $HOSTNAME"
+
+        hostname "$HOSTNAME"
+
+        mkdir -p /etc
+        printf '%s\n' "$HOSTNAME" > /etc/hostname
+
+        if [ -f /etc/hosts ]; then
+            if ! grep -Eq "[[:space:]]$HOSTNAME([[:space:]]|$)" /etc/hosts; then
+                printf '127.0.0.1\tlocalhost %s\n' "$HOSTNAME" >> /etc/hosts
+            fi
+        else
+            cat > /etc/hosts <<EOF
+127.0.0.1 localhost $HOSTNAME
+EOF
+        fi
+    else
+        log "hostname already set: $HOSTNAME"
+    fi
+}
+
+ensure_ip_forward() {
+    local current
+
+    current="$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)"
+    if [ "$current" != "1" ]; then
+        log "enabling IPv4 forwarding"
+        echo 1 > /proc/sys/net/ipv4/ip_forward
+    fi
+
+    mkdir -p /etc/sysctl.d
+    cat > /etc/sysctl.d/99-monok8s.conf <<'EOF'
+net.ipv4.ip_forward = 1
+EOF
+}
+
+print_summary() {
+    log "node configuration applied"
+    log "hostname: $HOSTNAME"
+    log "interface: $MGMT_IFACE"
+    log "address: $MGMT_ADDRESS"
+    if [ -n "${MGMT_GATEWAY:-}" ]; then
+        log "gateway: $MGMT_GATEWAY"
+    else
+        log "gateway: <not set>"
+    fi
+}
+
+main() {
+    load_config
+    validate_config
+    check_prereqs
+
+    ensure_ip_forward
+    configure_mgmt_interface
+    set_hostname_if_needed
+    print_summary
+}
+
+main "$@"
\ No newline at end of file
diff --git a/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh b/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh
new file mode 100755
index 0000000..9752bd2
--- /dev/null
+++ b/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh
@@ -0,0 +1,436 @@
+#!/bin/sh
+set -eu
+
+CONFIG_DIR="${CONFIG_DIR:-/opt/monok8s/config}"
+CLUSTER_ENV="${CONFIG_DIR}/cluster.env"
+KUBEADM_CONFIG_OUT="${KUBEADM_CONFIG_OUT:-/tmp/kubeadm-init.yaml}"
+
+log() {
+    echo "[monok8s] $*"
+}
+
+fail() {
+    echo "[monok8s] ERROR: $*" >&2
+    exit 1
+}
+
+need_cmd() {
+    command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
+}
+
+require_file() {
+    [ -f "$1" ] || fail "required file not found: $1"
+}
+
+load_config() {
+    require_file "$CLUSTER_ENV"
+
+    # shellcheck disable=SC1090
+    . "$CLUSTER_ENV"
+
+    : "${KUBERNETES_VERSION:?KUBERNETES_VERSION is required}"
+    : "${NODE_NAME:?NODE_NAME is required}"
+    : "${APISERVER_ADVERTISE_ADDRESS:?APISERVER_ADVERTISE_ADDRESS is required}"
+
+    POD_SUBNET="${POD_SUBNET:-10.244.0.0/16}"
+    SERVICE_SUBNET="${SERVICE_SUBNET:-10.96.0.0/12}"
+    CLUSTER_NAME="${CLUSTER_NAME:-monok8s}"
+    CLUSTER_DOMAIN="${CLUSTER_DOMAIN:-cluster.local}"
+    CONTAINER_RUNTIME_ENDPOINT="${CONTAINER_RUNTIME_ENDPOINT:-unix:///var/run/crio/crio.sock}"
+    SANS="${SANS:-}"
+    ALLOW_SCHEDULING_ON_CONTROL_PLANE="${ALLOW_SCHEDULING_ON_CONTROL_PLANE:-yes}"
+    SKIP_IMAGE_CHECK="${SKIP_IMAGE_CHECK:-no}"
+    KUBECONFIG_USER_HOME="${KUBECONFIG_USER_HOME:-/root}"
+
+    BOOTSTRAP_MODE="${BOOTSTRAP_MODE:-init}"
+    JOIN_KIND="${JOIN_KIND:-worker}"
+    API_SERVER_ENDPOINT="${API_SERVER_ENDPOINT:-}"
+    BOOTSTRAP_TOKEN="${BOOTSTRAP_TOKEN:-}"
+    DISCOVERY_TOKEN_CA_CERT_HASH="${DISCOVERY_TOKEN_CA_CERT_HASH:-}"
+    CONTROL_PLANE_CERT_KEY="${CONTROL_PLANE_CERT_KEY:-}"
+    CNI_PLUGIN="${CNI_PLUGIN:-none}"
+}
+
+validate_config() {
+    case "$BOOTSTRAP_MODE" in
+        init)
+            ;;
+        join)
+            : "${API_SERVER_ENDPOINT:?API_SERVER_ENDPOINT is required for join mode}"
+            : "${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN is required for join mode}"
+            : "${DISCOVERY_TOKEN_CA_CERT_HASH:?DISCOVERY_TOKEN_CA_CERT_HASH is required for join mode}"
+
+            case "$JOIN_KIND" in
+                worker|control-plane)
+                    ;;
+                *)
+                    fail "JOIN_KIND must be 'worker' or 'control-plane'"
+                    ;;
+            esac
+
+            if [ "$JOIN_KIND" = "control-plane" ]; then
+                : "${CONTROL_PLANE_CERT_KEY:?CONTROL_PLANE_CERT_KEY is required for JOIN_KIND=control-plane}"
+            fi
+            ;;
+        *)
+            fail "BOOTSTRAP_MODE must be 'init' or 'join'"
+            ;;
+    esac
+}
+
+check_prereqs() {
+    need_cmd kubeadm
+    need_cmd kubelet
+    need_cmd kubectl
+    need_cmd crictl
+    need_cmd rc-service
+    need_cmd awk
+	need_cmd ip
+    need_cmd grep
+    need_cmd sed
+    need_cmd hostname
+}
+
+check_apiserver_reachable() {
+    host="${API_SERVER_ENDPOINT%:*}"
+    port="${API_SERVER_ENDPOINT##*:}"
+
+    need_cmd nc
+
+    log "checking API server reachability: ${host}:${port}"
+
+    for _ in $(seq 1 20); do
+        if nc -z "$host" "$port" >/dev/null 2>&1; then
+            log "API server is reachable"
+            return 0
+        fi
+        sleep 1
+    done
+
+    fail "cannot reach API server at ${host}:${port}"
+}
+
+start_crio() {
+	rc-service crio start
+}
+
+check_crio_running() {
+    log "waiting for CRI-O to become ready..."
+
+    last_status="unknown"
+
+    for _ in $(seq 1 30); do
+        if rc-service crio status >/dev/null 2>&1; then
+            last_status="service-running"
+            if crictl --runtime-endpoint "$CONTAINER_RUNTIME_ENDPOINT" info >/dev/null 2>&1; then
+                log "CRI-O is up"
+                return 0
+            fi
+            last_status="service-running-but-runtime-not-ready"
+        else
+            last_status="service-not-running"
+        fi
+        sleep 1
+    done
+
+    fail "CRI-O did not become ready in time (${last_status})"
+}
+
+image_present() {
+    wanted="$1"
+
+    repo="${wanted%:*}"
+    tag="${wanted##*:}"
+
+    crictl --runtime-endpoint "$CONTAINER_RUNTIME_ENDPOINT" images \
+    | awk 'NR>1 { print $1 ":" $2 }' \
+    | grep -Fx "$repo:$tag" >/dev/null 2>&1
+}
+
+check_required_images() {
+    [ "$SKIP_IMAGE_CHECK" = "yes" ] && {
+        log "skipping image check (SKIP_IMAGE_CHECK=yes)"
+        return 0
+    }
+
+    log "checking required Kubernetes images for $KUBERNETES_VERSION..."
+
+    missing_any=0
+
+    for img in $(kubeadm config images list --kubernetes-version "$KUBERNETES_VERSION"); do
+        if image_present "$img"; then
+            log "found image: $img"
+        else
+            echo "[monok8s] MISSING image: $img" >&2
+            missing_any=1
+        fi
+    done
+
+    [ "$missing_any" -eq 0 ] || fail "preload the Kubernetes images before bootstrapping"
+
+    log "all required images are present"
+}
+
+check_not_already_bootstrapped() {
+    case "$BOOTSTRAP_MODE" in
+        init)
+            if [ -f /etc/kubernetes/admin.conf ]; then
+                fail "cluster already appears initialized (/etc/kubernetes/admin.conf exists)"
+            fi
+            ;;
+        join)
+            if [ -f /etc/kubernetes/kubelet.conf ]; then
+                fail "node already appears joined (/etc/kubernetes/kubelet.conf exists)"
+            fi
+            ;;
+    esac
+}
+
+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
+}
+
+generate_kubeadm_config() {
+    log "generating kubeadm config at $KUBEADM_CONFIG_OUT..."
+
+    SAN_LINES=""
+    if [ -n "${SANS:-}" ]; then
+        old_ifs="$IFS"
+        IFS=','
+        for san in $SANS; do
+            san_trimmed="$(echo "$san" | sed 's/^ *//;s/ *$//')"
+            [ -n "$san_trimmed" ] && SAN_LINES="${SAN_LINES}    - \"${san_trimmed}\"
+"
+        done
+        IFS="$old_ifs"
+    fi
+
+    cat > "$KUBEADM_CONFIG_OUT" <<EOF
+apiVersion: kubeadm.k8s.io/v1beta4
+kind: InitConfiguration
+localAPIEndpoint:
+  advertiseAddress: ${APISERVER_ADVERTISE_ADDRESS}
+  bindPort: 6443
+nodeRegistration:
+  name: ${NODE_NAME}
+  criSocket: ${CONTAINER_RUNTIME_ENDPOINT}
+  imagePullPolicy: IfNotPresent
+  kubeletExtraArgs:
+    - name: hostname-override
+      value: "${NODE_NAME}"
+    - name: node-ip
+      value: "${APISERVER_ADVERTISE_ADDRESS}"
+    - name: pod-manifest-path
+      value: "/etc/kubernetes/manifests"
+---
+apiVersion: kubeadm.k8s.io/v1beta4
+kind: ClusterConfiguration
+clusterName: ${CLUSTER_NAME}
+kubernetesVersion: ${KUBERNETES_VERSION}
+networking:
+  podSubnet: ${POD_SUBNET}
+  serviceSubnet: ${SERVICE_SUBNET}
+  dnsDomain: ${CLUSTER_DOMAIN}
+apiServer:
+  certSANs:
+    - "${APISERVER_ADVERTISE_ADDRESS}"
+${SAN_LINES}---
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration
+cgroupDriver: cgroupfs
+containerRuntimeEndpoint: ${CONTAINER_RUNTIME_ENDPOINT}
+EOF
+}
+
+run_kubeadm_init() {
+    log "running kubeadm init..."
+    kubeadm init --config "$KUBEADM_CONFIG_OUT"
+}
+
+require_local_ip() {
+    wanted_ip="$1"
+
+    ip -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -Fx "$wanted_ip" >/dev/null 2>&1 \
+        || fail "required local IP is not present on any interface: $wanted_ip"
+}
+
+validate_network_requirements() {
+    case "$BOOTSTRAP_MODE" in
+        init)
+            require_local_ip "$APISERVER_ADVERTISE_ADDRESS"
+            ;;
+        join)
+            require_local_ip "$APISERVER_ADVERTISE_ADDRESS"
+            check_apiserver_reachable
+            ;;
+        *)
+            fail "unsupported BOOTSTRAP_MODE: $BOOTSTRAP_MODE"
+            ;;
+    esac
+}
+
+setup_local_kubectl() {
+    kube_dir="${KUBECONFIG_USER_HOME}/.kube"
+    log "setting up local kubectl config in ${kube_dir}/config..."
+
+    mkdir -p "$kube_dir"
+    cp /etc/kubernetes/admin.conf "${kube_dir}/config"
+    chmod 600 "${kube_dir}/config"
+
+    if [ "$KUBECONFIG_USER_HOME" = "/root" ]; then
+        mkdir -p /etc/profile.d
+        cat > /etc/profile.d/kubeconfig.sh <<'EOF'
+export KUBECONFIG=/root/.kube/config
+EOF
+        chmod 644 /etc/profile.d/kubeconfig.sh
+    fi
+}
+
+wait_for_node() {
+    log "waiting for node registration: $NODE_NAME"
+
+    for _ in $(seq 1 60); do
+        if kubectl --kubeconfig /etc/kubernetes/admin.conf get node "$NODE_NAME" >/dev/null 2>&1; then
+            return 0
+        fi
+        sleep 1
+    done
+
+    fail "node $NODE_NAME did not register in time"
+}
+
+apply_local_node_metadata_if_possible() {
+    if [ "$BOOTSTRAP_MODE" != "init" ]; then
+        log "skipping node labels/annotations from this node (not control-plane init mode)"
+        return 0
+    fi
+
+    wait_for_node
+
+    if [ -n "${NODE_ANNOTATIONS:-}" ]; then
+        kubectl --kubeconfig /etc/kubernetes/admin.conf annotate node "$NODE_NAME" $(printf '%s' "$NODE_ANNOTATIONS" | tr ',' ' ') --overwrite
+    fi
+    if [ -n "${NODE_LABELS:-}" ]; then
+        kubectl --kubeconfig /etc/kubernetes/admin.conf label node "$NODE_NAME" $(printf '%s' "$NODE_LABELS" | tr ',' ' ') --overwrite
+    fi
+}
+
+install_cni_if_requested() {
+    case "${CNI_PLUGIN}" in
+        none)
+            if [ -f /etc/cni/net.d/10-crio-bridge.conflist ]; then
+                mv /etc/cni/net.d/10-crio-bridge.conflist \
+                   /etc/cni/net.d/10-crio-bridge.conflist.disabled
+            fi
+            log "bootstrap bridge CNI disabled; install a cluster CNI (e.g., flannel) for pod networking"
+            ;;
+        bridge)
+            if [ -f /etc/cni/net.d/10-crio-bridge.conflist.disabled ]; then
+                mv /etc/cni/net.d/10-crio-bridge.conflist.disabled \
+                   /etc/cni/net.d/10-crio-bridge.conflist
+            fi
+            log "bootstrap bridge CNI enabled"
+            ;;
+        *)
+            fail "unsupported CNI_PLUGIN: ${CNI_PLUGIN}"
+            ;;
+    esac
+}
+
+allow_single_node_scheduling() {
+    if [ "$ALLOW_SCHEDULING_ON_CONTROL_PLANE" != "yes" ]; then
+        log "leaving control-plane taint in place"
+        return 0
+    fi
+
+    log "removing control-plane taint so this single node can schedule workloads..."
+    kubectl --kubeconfig /etc/kubernetes/admin.conf taint nodes "$NODE_NAME" node-role.kubernetes.io/control-plane- >/dev/null 2>&1 || true
+}
+
+print_next_steps() {
+    echo
+    echo "[monok8s] bootstrap complete"
+    echo
+
+    case "$BOOTSTRAP_MODE" in
+        init)
+            cat <<EOF
+Try these now:
+
+  export KUBECONFIG=/root/.kube/config
+  kubectl get nodes -o wide
+  kubectl describe nodes
+
+Notes:
+- On a fresh cluster without a CNI, nodes may stay NotReady.
+- If you want pods to run on this same node, keep ALLOW_SCHEDULING_ON_CONTROL_PLANE=yes.
+EOF
+            ;;
+        join)
+            cat <<EOF
+This node has attempted to join the cluster.
+
+Check from the control-plane node:
+
+  kubectl get nodes -o wide
+  kubectl describe node ${NODE_NAME}
+
+Notes:
+- This node can join without a CNI.
+- Without a cluster CNI, the node may remain NotReady.
+EOF
+            ;;
+    esac
+}
+
+main() {
+    load_config
+    validate_config
+
+    check_prereqs
+    validate_network_requirements
+    check_not_already_bootstrapped
+
+    install_cni_if_requested
+    start_crio
+    check_crio_running
+
+    case "$BOOTSTRAP_MODE" in
+        init)
+            check_required_images
+            generate_kubeadm_config
+            run_kubeadm_init
+            rc-service kubelet restart
+            setup_local_kubectl
+            apply_local_node_metadata_if_possible
+            allow_single_node_scheduling
+            ;;
+        join)
+            run_kubeadm_join
+            ;;
+    esac
+
+    print_next_steps
+}
+
+main "$@"
diff --git a/alpine/rootfs-extra/opt/scripts/bootstrap-control-plane.sh b/alpine/rootfs-extra/opt/scripts/bootstrap-control-plane.sh
deleted file mode 100755
index eccddb7..0000000
--- a/alpine/rootfs-extra/opt/scripts/bootstrap-control-plane.sh
+++ /dev/null
@@ -1,377 +0,0 @@
-#!/bin/sh
-set -eu
-
-CONFIG_DIR="${CONFIG_DIR:-/opt/monok8s/config}"
-CLUSTER_ENV="${CONFIG_DIR}/cluster.env"
-KUBEADM_CONFIG_OUT="${KUBEADM_CONFIG_OUT:-/tmp/kubeadm-init.yaml}"
-
-log() {
-    echo "[monok8s] $*"
-}
-
-fail() {
-    echo "[monok8s] ERROR: $*" >&2
-    exit 1
-}
-
-need_cmd() {
-    command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
-}
-
-require_file() {
-    [ -f "$1" ] || fail "required file not found: $1"
-}
-
-load_config() {
-    require_file "$CLUSTER_ENV"
-
-    # shellcheck disable=SC1090
-    . "$CLUSTER_ENV"
-
-    : "${KUBERNETES_VERSION:?KUBERNETES_VERSION is required}"
-    : "${NODE_NAME:?NODE_NAME is required}"
-    : "${APISERVER_ADVERTISE_ADDRESS:?APISERVER_ADVERTISE_ADDRESS is required}"
-
-    POD_SUBNET="${POD_SUBNET:-10.244.0.0/16}"
-    SERVICE_SUBNET="${SERVICE_SUBNET:-10.96.0.0/12}"
-    CLUSTER_NAME="${CLUSTER_NAME:-monok8s}"
-    CLUSTER_DOMAIN="${CLUSTER_DOMAIN:-cluster.local}"
-    CONTAINER_RUNTIME_ENDPOINT="${CONTAINER_RUNTIME_ENDPOINT:-unix:///var/run/crio/crio.sock}"
-    SANS="${SANS:-}"
-    ALLOW_SCHEDULING_ON_CONTROL_PLANE="${ALLOW_SCHEDULING_ON_CONTROL_PLANE:-yes}"
-    SKIP_IMAGE_CHECK="${SKIP_IMAGE_CHECK:-no}"
-    KUBECONFIG_USER_HOME="${KUBECONFIG_USER_HOME:-/root}"
-}
-
-check_prereqs() {
-    need_cmd kubeadm
-    need_cmd kubelet
-    need_cmd kubectl
-    need_cmd crictl
-    need_cmd rc-service
-    need_cmd awk
-    need_cmd grep
-    need_cmd sed
-    need_cmd hostname
-
-    [ -S /var/run/crio/crio.sock ] || fail "CRI-O socket not found at /var/run/crio/crio.sock"
-}
-
-set_hostname_if_needed() {
-    current_hostname="$(hostname 2>/dev/null || true)"
-
-    if [ "$current_hostname" != "$NODE_NAME" ]; then
-        log "setting hostname to $NODE_NAME"
-
-        hostname "$NODE_NAME"
-
-        mkdir -p /etc
-        printf '%s\n' "$NODE_NAME" > /etc/hostname
-
-        # Keep localhost mapping sane even without DNS.
-        if [ -f /etc/hosts ]; then
-            if ! grep -Eq "[[:space:]]$NODE_NAME([[:space:]]|\$)" /etc/hosts; then
-                printf '127.0.0.1\tlocalhost %s\n' "$NODE_NAME" >> /etc/hosts
-            fi
-        else
-            cat > /etc/hosts <<EOF
-127.0.0.1 localhost $NODE_NAME
-EOF
-        fi
-    fi
-}
-
-ensure_ip_forward() {
-    current="$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)"
-    if [ "$current" != "1" ]; then
-        log "enabling IPv4 forwarding"
-        echo 1 > /proc/sys/net/ipv4/ip_forward
-    fi
-
-    mkdir -p /etc/sysctl.d
-    cat > /etc/sysctl.d/99-monok8s.conf <<'EOF'
-net.ipv4.ip_forward = 1
-EOF
-}
-
-ensure_kubelet_binary() {
-    if command -v kubelet >/dev/null 2>&1; then
-        return 0
-    fi
-
-    if [ -x /usr/local/bin/kubelet ]; then
-        export PATH="/usr/local/bin:$PATH"
-        return 0
-    fi
-
-    if [ -x /opt/cni/bin/kubelet ]; then
-        export PATH="/opt/cni/bin:$PATH"
-        return 0
-    fi
-
-    fail "kubelet binary not found; install kubelet into /usr/local/bin or make sure PATH includes it"
-}
-
-ensure_kubelet_service() {
-    if ! rc-service kubelet status >/dev/null 2>&1; then
-        if [ ! -f /etc/init.d/kubelet ]; then
-            fail "kubelet OpenRC service does not exist at /etc/init.d/kubelet"
-        fi
-
-        log "starting kubelet service"
-        rc-service kubelet start || fail "failed to start kubelet"
-    fi
-}
-
-check_crio_running() {
-    log "checking CRI-O status..."
-
-    if ! rc-service crio status >/dev/null 2>&1; then
-        fail "crio service is not running"
-    fi
-
-    if ! crictl --runtime-endpoint "$CONTAINER_RUNTIME_ENDPOINT" info >/dev/null 2>&1; then
-        fail "crictl cannot talk to CRI-O via $CONTAINER_RUNTIME_ENDPOINT"
-    fi
-
-    log "CRI-O is up"
-}
-
-normalize_image_ref() {
-    # Add docker.io/library/ for bare images like pause:3.10 if needed.
-    # kubeadm usually emits full refs already, so this is just defensive.
-    ref="$1"
-    case "$ref" in
-        */* ) echo "$ref" ;;
-        * ) echo "docker.io/library/$ref" ;;
-    esac
-}
-
-image_present() {
-    wanted="$1"
-
-    repo="${wanted%:*}"
-    tag="${wanted##*:}"
-
-    crictl --runtime-endpoint "$CONTAINER_RUNTIME_ENDPOINT" images \
-    | awk 'NR>1 { print $1 ":" $2 }' \
-    | grep -Fx "$repo:$tag" >/dev/null 2>&1
-}
-
-check_required_images() {
-    [ "$SKIP_IMAGE_CHECK" = "yes" ] && {
-        log "skipping image check (SKIP_IMAGE_CHECK=yes)"
-        return 0
-    }
-
-    log "checking required Kubernetes images for $KUBERNETES_VERSION..."
-
-    missing_any=0
-
-    for img in $(kubeadm config images list --kubernetes-version "$KUBERNETES_VERSION"); do
-        if image_present "$img"; then
-            log "found image: $img"
-        else
-            echo "[monok8s] MISSING image: $img" >&2
-            missing_any=1
-        fi
-    done
-
-    [ "$missing_any" -eq 0 ] || fail "preload the Kubernetes images before bootstrapping"
-
-    log "all required images are present"
-}
-
-check_node_name() {
-    current_hostname="$(hostname)"
-    if [ "$current_hostname" != "$NODE_NAME" ]; then
-        log "warning: hostname is '$current_hostname' but NODE_NAME is '$NODE_NAME'"
-        log "kubeadm will use NODE_NAME from config"
-    fi
-}
-
-check_already_initialized() {
-    if [ -f /etc/kubernetes/admin.conf ]; then
-        fail "cluster already appears initialized (/etc/kubernetes/admin.conf exists)"
-    fi
-}
-
-generate_kubeadm_config() {
-    log "generating kubeadm config at $KUBEADM_CONFIG_OUT..."
-
-    SAN_LINES=""
-    if [ -n "${SANS:-}" ]; then
-        old_ifs="$IFS"
-        IFS=','
-        for san in $SANS; do
-            san_trimmed="$(echo "$san" | sed 's/^ *//;s/ *$//')"
-            [ -n "$san_trimmed" ] && SAN_LINES="${SAN_LINES}    - \"${san_trimmed}\"
-"
-        done
-        IFS="$old_ifs"
-    fi
-
-    cat > "$KUBEADM_CONFIG_OUT" <<EOF
-apiVersion: kubeadm.k8s.io/v1beta4
-kind: InitConfiguration
-localAPIEndpoint:
-  advertiseAddress: ${APISERVER_ADVERTISE_ADDRESS}
-  bindPort: 6443
-nodeRegistration:
-  name: ${NODE_NAME}
-  criSocket: ${CONTAINER_RUNTIME_ENDPOINT}
-  imagePullPolicy: IfNotPresent
-  kubeletExtraArgs:
-    - name: hostname-override
-      value: "${NODE_NAME}"
-    - name: node-ip
-      value: "${APISERVER_ADVERTISE_ADDRESS}"
-    - name: pod-manifest-path
-      value: "/etc/kubernetes/manifests"
----
-apiVersion: kubeadm.k8s.io/v1beta4
-kind: ClusterConfiguration
-clusterName: ${CLUSTER_NAME}
-kubernetesVersion: ${KUBERNETES_VERSION}
-networking:
-  podSubnet: ${POD_SUBNET}
-  serviceSubnet: ${SERVICE_SUBNET}
-  dnsDomain: ${CLUSTER_DOMAIN}
-apiServer:
-  certSANs:
-    - "${APISERVER_ADVERTISE_ADDRESS}"
-${SAN_LINES}---
-apiVersion: kubelet.config.k8s.io/v1beta1
-kind: KubeletConfiguration
-cgroupDriver: cgroupfs
-containerRuntimeEndpoint: ${CONTAINER_RUNTIME_ENDPOINT}
-EOF
-}
-
-run_kubeadm_init() {
-    log "running kubeadm init..."
-    kubeadm init --config "$KUBEADM_CONFIG_OUT"
-}
-
-warn_dummy_interface() {
-    YELLOW="$(printf '\033[1;33m')"
-    RESET="$(printf '\033[0m')"
-
-    echo
-    printf '%s' "$YELLOW"
-    echo "[monok8s] WARNING: No suitable network interface found for API server address."
-    echo "[monok8s]          Creating a temporary dummy interface for bootstrap."
-    echo "[monok8s]          This configuration is intended for local/testing use only."
-    echo "[monok8s]          Please configure a real network interface for production."
-    printf '%s' "$RESET"
-    echo
-}
-
-ensure_apiserver_address_present() {
-    : "${APISERVER_ADVERTISE_ADDRESS:?APISERVER_ADVERTISE_ADDRESS is required}"
-
-    DUMMY_IFACE="${DUMMY_IFACE:-dummy0}"
-    DUMMY_PREFIX_LEN="${DUMMY_PREFIX_LEN:-32}"
-
-    # If the address already exists on an interface, we're done.
-    if ip -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -Fx "$APISERVER_ADVERTISE_ADDRESS" >/dev/null 2>&1; then
-        log "API server advertise address already present: $APISERVER_ADVERTISE_ADDRESS"
-        return 0
-    fi
-
-    warn_dummy_interface
-
-    if ! command -v modprobe >/dev/null 2>&1; then
-        fail "modprobe not found; cannot create dummy interface"
-    fi
-
-    log "loading dummy kernel module..."
-    modprobe dummy || fail "failed to load dummy kernel module"
-
-    if ! ip link show "$DUMMY_IFACE" >/dev/null 2>&1; then
-        log "creating dummy interface: $DUMMY_IFACE"
-        ip link add "$DUMMY_IFACE" type dummy || fail "failed to create dummy interface $DUMMY_IFACE"
-    else
-        log "dummy interface already exists: $DUMMY_IFACE"
-    fi
-
-    log "assigning ${APISERVER_ADVERTISE_ADDRESS}/${DUMMY_PREFIX_LEN} to $DUMMY_IFACE"
-    ip addr add "${APISERVER_ADVERTISE_ADDRESS}/${DUMMY_PREFIX_LEN}" dev "$DUMMY_IFACE" 2>/dev/null || true
-
-    log "bringing up $DUMMY_IFACE"
-    ip link set "$DUMMY_IFACE" up || fail "failed to bring up $DUMMY_IFACE"
-
-    if ! ip -o addr show dev "$DUMMY_IFACE" | awk '{print $4}' | cut -d/ -f1 | grep -Fx "$APISERVER_ADVERTISE_ADDRESS" >/dev/null 2>&1; then
-        fail "dummy interface came up, but ${APISERVER_ADVERTISE_ADDRESS} is still not present"
-    fi
-
-    log "dummy interface ready: $DUMMY_IFACE -> ${APISERVER_ADVERTISE_ADDRESS}/${DUMMY_PREFIX_LEN}"
-}
-
-setup_local_kubectl() {
-    kube_dir="${KUBECONFIG_USER_HOME}/.kube"
-    log "setting up local kubectl config in ${kube_dir}/config..."
-
-    mkdir -p "$kube_dir"
-    cp /etc/kubernetes/admin.conf "${kube_dir}/config"
-    chmod 600 "${kube_dir}/config"
-
-    if [ "$KUBECONFIG_USER_HOME" = "/root" ]; then
-        mkdir -p /etc/profile.d
-        cat > /etc/profile.d/kubeconfig.sh <<'EOF'
-export KUBECONFIG=/root/.kube/config
-EOF
-        chmod 644 /etc/profile.d/kubeconfig.sh
-    fi
-}
-
-allow_single_node_scheduling() {
-    if [ "$ALLOW_SCHEDULING_ON_CONTROL_PLANE" != "yes" ]; then
-        log "leaving control-plane taint in place"
-        return 0
-    fi
-
-    log "removing control-plane taint so this single node can schedule workloads..."
-    kubectl --kubeconfig /etc/kubernetes/admin.conf taint nodes "$NODE_NAME" node-role.kubernetes.io/control-plane- >/dev/null 2>&1 || true
-}
-
-print_next_steps() {
-    cat <<EOF
-
-[monok8s] bootstrap complete
-
-Try these now:
-
-  export KUBECONFIG=/root/.kube/config
-  kubectl get nodes -o wide
-  kubectl describe nodes
-
-Notes:
-- On a fresh single-node control plane, the node may stay NotReady until you install a CNI.
-- 'kubectl describe nodes' will still work as soon as the API server is up and your kubeconfig is installed.
-- If you want pods to run on this same node, keep ALLOW_SCHEDULING_ON_CONTROL_PLANE=yes.
-EOF
-}
-
-main() {
-	load_config
-	set_hostname_if_needed
-	ensure_ip_forward
-	ensure_apiserver_address_present
-
-	check_prereqs
-	check_already_initialized
-	check_crio_running
-	check_required_images
-
-	generate_kubeadm_config
-	run_kubeadm_init
-
-	rc-service kubelet restart
-
-	setup_local_kubectl
-	allow_single_node_scheduling
-	print_next_steps
-}
-
-main "$@"
diff --git a/configs/cluster.env.default b/configs/cluster.env.default
index c326e84..efc09b5 100644
--- a/configs/cluster.env.default
+++ b/configs/cluster.env.default
@@ -9,6 +9,24 @@ POD_SUBNET=10.244.0.0/16
 SERVICE_SUBNET=10.96.0.0/12
 CLUSTER_DOMAIN=cluster.local
 
+# Bootstrap mode: init, join
+BOOTSTRAP_MODE=init
+
+# For join mode: worker, control-plane
+JOIN_KIND=worker
+API_SERVER_ENDPOINT=
+BOOTSTRAP_TOKEN=
+DISCOVERY_TOKEN_CA_CERT_HASH=
+CONTROL_PLANE_CERT_KEY=
+
+# none: Install manually
+# bridge: CRIO's default bridge CNI
+CNI_PLUGIN=none
+
+# Node registration metadata
+NODE_LABELS=topology.kubernetes.io/zone=lab,node.kubernetes.io/instance-type=mono-gateway
+NODE_ANNOTATIONS=mono.si/board=ls1046a,mono.si/image-version=dev
+
 # Optional
 # Extra API server SANs, comma-separated
 SANS=127.0.0.1,localhost,monok8s-master
diff --git a/configs/node.env.default b/configs/node.env.default
new file mode 100644
index 0000000..c7ab0f8
--- /dev/null
+++ b/configs/node.env.default
@@ -0,0 +1,7 @@
+HOSTNAME=monok8s-master
+
+# RJ45 Ports from left to right (interface name inside the OS)
+#   - eth1, eth2, eth0
+MGMT_IFACE=eth0
+MGMT_ADDRESS=10.0.0.13/24
+MGMT_GATEWAY=10.0.0.1
diff --git a/initramfs/rootfs-extra/bin/flash-emmc.sh.tmpl b/initramfs/rootfs-extra/bin/flash-emmc.sh.tmpl
index af75fcf..b3ed5e1 100755
--- a/initramfs/rootfs-extra/bin/flash-emmc.sh.tmpl
+++ b/initramfs/rootfs-extra/bin/flash-emmc.sh.tmpl
@@ -2,13 +2,69 @@
 set -eu
 
 MNT=/mnt
+ROOT_MNT=/mnt-root
 USB_DEV=/dev/sda
 EMMC_DEV=/dev/mmcblk0
-DEFAULT_GLOB="${MNT}/$${BUILD_TAG}_RELEASE_IMAGE"
+EMMC_ROOT_PART=/dev/mmcblk0p1
+CONFIG_DIR=/opt/monok8s/config
 
-mkdir -p "$MNT"
+DEFAULT_GLOB="${MNT}/${BUILD_TAG}_RELEASE_IMAGE"
 
-echo "[*] Mounting USB..."
+cleanup() {
+    umount "$ROOT_MNT" 2>/dev/null || true
+    umount "$MNT" 2>/dev/null || true
+}
+
+log() {
+    echo "[*] $*"
+}
+
+warn() {
+    echo "[!] $*" >&2
+}
+
+die() {
+    echo "[!] ERROR: $*" >&2
+    exit 1
+}
+
+reread_partitions() {
+    log "Re-reading partition table..."
+    blockdev --rereadpt "$EMMC_DEV" 2>/dev/null || true
+    partprobe "$EMMC_DEV" 2>/dev/null || true
+    partx -u "$EMMC_DEV" 2>/dev/null || true
+    mdev -s 2>/dev/null || true
+}
+
+get_sectors() {
+    devname="${1##*/}"
+    cat "/sys/class/block/$devname/size" 2>/dev/null || echo 0
+}
+
+wait_for_partition_ready() {
+    part="$1"
+    timeout="${2:-15}"
+
+    i=0
+    while [ "$i" -lt "$timeout" ]; do
+        if [ -b "$part" ]; then
+            sectors="$(get_sectors "$part")"
+            if [ "${sectors:-0}" -gt 0 ]; then
+                log "Partition $part is present with $sectors sectors"
+                return 0
+            fi
+        fi
+        i=$((i + 1))
+        sleep 1
+    done
+
+    return 1
+}
+
+mkdir -p "$MNT" "$ROOT_MNT"
+trap cleanup EXIT
+
+log "Mounting USB..."
 mount "$USB_DEV" "$MNT"
 
 # Find a default image (first match)
@@ -23,45 +79,83 @@ else
 fi
 echo
 
-# Prompt user
 printf "Image Location (%s): " "${DEFAULT_IMG:-/mnt/...}"
 read -r IMG
 
-# Use default if empty
 if [ -z "$IMG" ]; then
     IMG="$DEFAULT_IMG"
 fi
 
-# Validate
-if [ -z "$IMG" ] || [ ! -f "$IMG" ]; then
-    echo "Invalid image: $IMG"
-    umount "$MNT" || true
-    exit 1
-fi
+[ -n "$IMG" ] || die "No image selected"
+[ -f "$IMG" ] || die "Invalid image: $IMG"
 
 echo
+echo "Looking for env files on USB..."
+ENV_FILES="$(find "$MNT" -maxdepth 1 -type f -name '*.env' | sort || true)"
+
+if [ -n "$ENV_FILES" ]; then
+    echo "Found env files:"
+    echo "$ENV_FILES" | sed 's/^/  /'
+else
+    echo "No *.env files found in $MNT"
+fi
+echo
+
 echo "About to write:"
-echo "  Image: $IMG"
+echo "  Image:  $IMG"
 echo "  Target: $EMMC_DEV"
 echo
 
 printf "Type 'YES' to continue: "
 read -r CONFIRM
 
-if [ "$CONFIRM" != "YES" ]; then
-    echo "Aborted."
-    umount "$MNT" || true
-    exit 1
-fi
+[ "$CONFIRM" = "YES" ] || die "Aborted."
 
-echo "[*] Flashing..."
+log "Flashing image to eMMC..."
 gunzip -c "$IMG" > "$EMMC_DEV"
 
-echo "[*] Syncing..."
+log "Syncing flash write..."
 sync
 
-echo "[*] Done."
-umount "$MNT" || true
+reread_partitions
+
+log "Waiting for partition nodes..."
+if ! wait_for_partition_ready "$EMMC_ROOT_PART" 15; then
+    warn "Initial partition scan did not settle; retrying after another reread..."
+    reread_partitions
+    sleep 2
+    wait_for_partition_ready "$EMMC_ROOT_PART" 15 || die "root partition not ready: $EMMC_ROOT_PART"
+fi
+
+log "Partition sizes:"
+echo "  $(basename "$EMMC_DEV"):      $(get_sectors "$EMMC_DEV") sectors"
+echo "  $(basename "$EMMC_ROOT_PART"): $(get_sectors "$EMMC_ROOT_PART") sectors"
+
+if [ -n "$ENV_FILES" ]; then
+    log "Mounting flashed root partition..."
+    mount "$EMMC_ROOT_PART" "$ROOT_MNT" || {
+        warn "Mount failed; retrying once after partition reread..."
+        reread_partitions
+        sleep 2
+        mount "$EMMC_ROOT_PART" "$ROOT_MNT"
+    }
+
+    log "Creating config dir: $CONFIG_DIR"
+    mkdir -p "$ROOT_MNT$CONFIG_DIR"
+
+    log "Copying env files..."
+    for f in $ENV_FILES; do
+        base="$(basename "$f")"
+        cp "$f" "$ROOT_MNT$CONFIG_DIR/$base"
+        echo "  copied: $base"
+    done
+
+    log "Syncing copied config..."
+    sync
+
+    umount "$ROOT_MNT"
+fi
 
-echo "[*] Rebooting..."
-reboot -f
+log "Done."
+log "Rebooting..."
+reboot -f
\ No newline at end of file
diff --git a/kernel-build/dts/mono-gateway-dk.dts b/kernel-build/dts/mono-gateway-dk.dts
index eaafe8e..d2d7aea 100644
--- a/kernel-build/dts/mono-gateway-dk.dts
+++ b/kernel-build/dts/mono-gateway-dk.dts
@@ -482,18 +482,21 @@
 	ethernet@e8000 {
 		phy-handle = <&sgmii_phy0>;
 		phy-connection-type = "sgmii";
+        managed = "in-band-status";
 		status = "okay";
 	};
 
 	ethernet@ea000 {
 		phy-handle = <&sgmii_phy1>;
 		phy-connection-type = "sgmii";
+        managed = "in-band-status";
 		status = "okay";
 	};
 
 	ethernet@e2000 {
 		phy-handle = <&sgmii_phy2>;
 		phy-connection-type = "sgmii";
+        managed = "in-band-status";
 		status = "okay";
 	};
 
diff --git a/makefile b/makefile
index 01285b6..2fa068c 100644
--- a/makefile
+++ b/makefile
@@ -20,6 +20,8 @@ CONFIGS_DIR          := configs
 SCRIPTS_DIR          := scripts
 CLUSTER_ENV_DEFAULT  := $(CONFIGS_DIR)/cluster.env.default
 CLUSTER_ENV          := $(OUT_DIR)/cluster.env
+NODE_ENV_DEFAULT     := configs/node.env.default
+NODE_ENV             := $(OUT_DIR)/node.env
 
 BOARD_ITB     := $(OUT_DIR)/board.itb
 INITRAMFS     := $(OUT_DIR)/initramfs.cpio.gz
@@ -211,14 +213,25 @@ check-functions:
 	      in_main && /^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*$$/ { \
 	          gsub(/^[ \t]+/, "", $$0); \
 	          print $$0 \
-	      }' ./alpine/rootfs-extra/opt/scripts/bootstrap-control-plane.sh \
+	      }' ./alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh \
 	      | sort -u > /tmp/called.txt
-	@grep -E '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' ./alpine/rootfs-extra/opt/scripts/bootstrap-control-plane.sh \
+	@grep -E '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' ./alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh \
 	      | sed 's/().*//' \
 	      | sort -u > /tmp/defined.txt
 	@echo "Missing functions:"
 	@comm -23 /tmp/called.txt /tmp/defined.txt || true
 
+# ---- node targets ------------------------------------------------------------
+
+node-config: $(NODE_ENV_DEFAULT) $(SCRIPTS_DIR)/merge-env.sh | $(OUT_DIR)
+	sh $(SCRIPTS_DIR)/merge-env.sh $(NODE_ENV_DEFAULT) $(NODE_ENV)
+
+node-defconfig: $(NODE_ENV_DEFAULT) | $(OUT_DIR)
+	cp $(NODE_ENV_DEFAULT) $(NODE_ENV)
+
+node-print:
+	@cat $(NODE_ENV)
+
 # ---- cluster targets ------------------------------------------------------------
 
 cluster-config: $(CLUSTER_ENV_DEFAULT) $(SCRIPTS_DIR)/merge-env.sh | $(OUT_DIR)