penguin/monok8s

k8s image for Mono Gateway Dev Kit

commit d9ffd1b44676b4ac59bc7ddfb0f419da8913090596a22e6961fac44ba40160dd

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-03-30T11:33:44Z
subjectUpdate ctl init to support env file
commit d9ffd1b44676b4ac59bc7ddfb0f419da8913090596a22e6961fac44ba40160dd
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-03-30T11:33:44Z

    Update ctl init to support env file
---
 alpine/configure-system.sh                         |   1 +
 alpine/rootfs-extra/etc/init.d/apply-node-config   |  23 -
 alpine/rootfs-extra/etc/init.d/bootstrap-cluster   |  19 +-
 .../rootfs-extra/opt/scripts/apply-node-config.sh  | 201 -----
 .../rootfs-extra/opt/scripts/bootstrap-cluster.sh  | 820 ---------------------
 clitools/pkg/cmd/initcmd/init.go                   | 129 +++-
 clitools/pkg/render/monoks.go                      |  16 +-
 clitools/pkg/templates/templates.go                | 133 ++--
 clitools/pkg/templates/values.go                   | 204 +++++
 configs/cluster.env.default                        |  68 +-
 configs/node.env.default                           |  14 -
 makefile                                           |  13 +-
 12 files changed, 450 insertions(+), 1191 deletions(-)

diff --git a/alpine/configure-system.sh b/alpine/configure-system.sh
index 9d931d1..77c1354 100755
--- a/alpine/configure-system.sh
+++ b/alpine/configure-system.sh
@@ -11,3 +11,4 @@ rc-update add fancontrol boot
 rc-update add loopback boot
 rc-update add hostname boot
 rc-update add localmount boot
+rc-update add bootstrap-cluster default
diff --git a/alpine/rootfs-extra/etc/init.d/apply-node-config b/alpine/rootfs-extra/etc/init.d/apply-node-config
deleted file mode 100755
index ccded75..0000000
--- a/alpine/rootfs-extra/etc/init.d/apply-node-config
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/sbin/openrc-run
-
-export PATH="/usr/local/bin:/usr/local/sbin:$PATH"
-
-name="Apply node config"
-description="Apply node configurations using node.env from /opt/monok8s/config"
-
-command="/opt/scripts/apply-node-config.sh"
-
-LOG_DIR="/var/log/monok8s"
-LOG_FILE="$LOG_DIR/apply-node-config.log"
-
-depend() {
-    need localmount
-}
-
-start() {
-    checkpath --directory "$LOG_DIR"
-
-    ebegin "Applying node config"
-    "$command" >>"$LOG_FILE" 2>&1
-    eend $?
-}
diff --git a/alpine/rootfs-extra/etc/init.d/bootstrap-cluster b/alpine/rootfs-extra/etc/init.d/bootstrap-cluster
index d3de5b7..e010305 100755
--- a/alpine/rootfs-extra/etc/init.d/bootstrap-cluster
+++ b/alpine/rootfs-extra/etc/init.d/bootstrap-cluster
@@ -2,23 +2,24 @@
 
 export PATH="/usr/local/bin:/usr/local/sbin:$PATH"
 
-name="Bootstrap cluster"
-description="Apply cluster configurations using node.env from /opt/monok8s/config"
+name="Bootstrap the Cluster"
+description="Runs the ctl init bootstrap sequence"
 
-command="/opt/scripts/bootstrap-cluster.sh"
+command="/usr/local/bin/ctl"
+command_args="init --env-file /opt/monok8s/config/cluster.env"
 
 LOG_DIR="/var/log/monok8s"
-LOG_FILE="$LOG_DIR/bootstrap.log"
+LOG_FILE="$LOG_DIR/bootstrap-cluster.log"
 
 depend() {
-    need apply-node-config
-    use net
+    need localmount
 }
 
 start() {
     checkpath --directory "$LOG_DIR"
-
-    ebegin "Applying cluster config"
-    "$command" >>"$LOG_FILE" 2>&1 &
+    ebegin "Starting the bootstrap sequence"
+    start-stop-daemon --start \
+        --exec "$command" \
+        -- $command_args >>"$LOG_FILE" 2>&1
     eend $?
 }
diff --git a/alpine/rootfs-extra/opt/scripts/apply-node-config.sh b/alpine/rootfs-extra/opt/scripts/apply-node-config.sh
deleted file mode 100755
index 6de0254..0000000
--- a/alpine/rootfs-extra/opt/scripts/apply-node-config.sh
+++ /dev/null
@@ -1,201 +0,0 @@
-#!/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:-}"
-    DNS_NAMESERVERS="${DNS_NAMESERVERS:-}"
-    DNS_SEARCH_DOMAINS="${DNS_SEARCH_DOMAINS:-}"
-}
-
-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
-
-    if [ -n "$DNS_NAMESERVERS" ]; then
-        for ns in $DNS_NAMESERVERS; do
-            case "$ns" in
-                10.96.0.10)
-                    fail "DNS_NAMESERVERS must not contain cluster DNS service IP (10.96.0.10)"
-                    ;;
-            esac
-        done
-    fi
-}
-
-check_prereqs() {
-	# only the special one, coreutils should not be checked
-    need_cmd ip
-    need_cmd hostname
-    need_cmd grep
-}
-
-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
-}
-
-configure_dns() {
-    local tmpfile
-    local ns_count=0
-
-    if [ -z "$DNS_NAMESERVERS" ]; then
-        log "DNS_NAMESERVERS not set; leaving /etc/resolv.conf unchanged"
-        return
-    fi
-
-    mkdir -p /etc
-    tmpfile="/etc/resolv.conf.monok8s.tmp"
-    : > "$tmpfile"
-
-    if [ -n "$DNS_SEARCH_DOMAINS" ]; then
-        printf 'search %s\n' "$DNS_SEARCH_DOMAINS" >> "$tmpfile"
-    fi
-
-    for ns in $DNS_NAMESERVERS; do
-        printf 'nameserver %s\n' "$ns" >> "$tmpfile"
-        ns_count=$((ns_count + 1))
-    done
-
-    [ "$ns_count" -gt 0 ] || fail "DNS_NAMESERVERS is set but no valid nameservers were parsed"
-
-    printf 'options timeout:2 attempts:3\n' >> "$tmpfile"
-
-    mv "$tmpfile" /etc/resolv.conf
-    log "configured /etc/resolv.conf from DNS_NAMESERVERS"
-}
-
-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
-
-    if [ -n "${DNS_NAMESERVERS:-}" ]; then
-        log "dns nameservers: $DNS_NAMESERVERS"
-    else
-        log "dns nameservers: <unchanged>"
-    fi
-
-    if [ -n "${DNS_SEARCH_DOMAINS:-}" ]; then
-        log "dns search: $DNS_SEARCH_DOMAINS"
-    else
-        log "dns search: <not set>"
-    fi
-}
-
-main() {
-    load_config
-    validate_config
-    check_prereqs
-
-    ensure_ip_forward
-    configure_mgmt_interface
-    configure_dns
-    set_hostname_if_needed
-    print_summary
-}
-
-main "$@"
diff --git a/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh b/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh
deleted file mode 100755
index 4477731..0000000
--- a/alpine/rootfs-extra/opt/scripts/bootstrap-cluster.sh
+++ /dev/null
@@ -1,820 +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}"
-ADMIN_KUBECONFIG="/etc/kubernetes/admin.conf"
-KUBELET_KUBECONFIG="/etc/kubernetes/kubelet.conf"
-
-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}"
-    KUBE_PROXY_NODEPORT_ADDRESSES="${KUBE_PROXY_NODEPORT_ADDRESSES:-primary}"
-
-    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}"
-}
-
-kubectl_admin() {
-    kubectl --kubeconfig "$ADMIN_KUBECONFIG" "$@"
-}
-
-kubectl_kubelet() {
-    kubectl --kubeconfig "$KUBELET_KUBECONFIG" "$@"
-}
-
-start_kubelet() {
-    log "starting kubelet..."
-    rc-service kubelet start >/dev/null 2>&1 || true
-}
-
-restart_kubelet() {
-    log "restarting kubelet..."
-    rc-service kubelet restart
-}
-
-check_kubelet_running() {
-    log "waiting for kubelet to become ready..."
-
-    last_status="unknown"
-
-    for _ in $(seq 1 30); do
-        if rc-service kubelet status >/dev/null 2>&1; then
-            log "kubelet is up"
-            return 0
-        fi
-        last_status="service-not-running"
-        sleep 1
-    done
-
-    fail "kubelet did not become ready in time (${last_status})"
-}
-
-is_local_control_plane_node() {
-    [ -f /etc/kubernetes/manifests/kube-apiserver.yaml ]
-}
-
-wait_for_local_apiserver() {
-    need_cmd nc
-
-    log "waiting for local API server on ${APISERVER_ADVERTISE_ADDRESS}:6443..."
-
-    for _ in $(seq 1 90); do
-        if nc -z "${APISERVER_ADVERTISE_ADDRESS}" 6443 >/dev/null 2>&1; then
-            log "local API server TCP port is reachable"
-            return 0
-        fi
-        sleep 2
-    done
-
-    fail "local API server did not become reachable on ${APISERVER_ADVERTISE_ADDRESS}:6443"
-}
-
-wait_for_admin_api() {
-    [ -f "$ADMIN_KUBECONFIG" ] || fail "missing admin kubeconfig: $ADMIN_KUBECONFIG"
-
-    log "waiting for Kubernetes API to respond via admin.conf..."
-
-    for _ in $(seq 1 90); do
-        if kubectl_admin version -o yaml >/dev/null 2>&1; then
-            log "Kubernetes API is responding"
-            return 0
-        fi
-        sleep 2
-    done
-
-    fail "Kubernetes API did not become ready in time"
-}
-
-wait_for_existing_cluster_if_needed() {
-    case "$BOOTSTRAP_MODE" in
-        init)
-            if [ -f "$ADMIN_KUBECONFIG" ]; then
-                start_kubelet
-                check_kubelet_running
-
-                if is_local_control_plane_node; then
-                    wait_for_local_apiserver
-                fi
-
-                wait_for_admin_api
-            fi
-            ;;
-        join)
-            if [ -f "$KUBELET_KUBECONFIG" ]; then
-                start_kubelet
-                check_kubelet_running
-            fi
-            ;;
-    esac
-}
-
-get_cluster_server_version() {
-    kubectl_admin version -o yaml 2>/dev/null \
-        | awk '
-            $1 == "serverVersion:" { in_server=1; next }
-            in_server && $1 == "gitVersion:" { print $2; exit }
-        '
-}
-
-get_api_server_version_from_kubelet_kubeconfig() {
-    kubectl_kubelet version -o yaml 2>/dev/null \
-        | awk '
-            $1 == "serverVersion:" { in_server=1; next }
-            in_server && $1 == "gitVersion:" { print $2; exit }
-        '
-}
-
-validate_cidr_list_or_primary() {
-    value="$1"
-
-    [ -n "$value" ] || return 0
-
-    if [ "$value" = "primary" ]; then
-        return 0
-    fi
-
-    old_ifs="$IFS"
-    IFS=','
-    for item in $value; do
-        trimmed="$(printf '%s' "$item" | sed 's/^ *//;s/ *$//')"
-        [ -n "$trimmed" ] || fail "KUBE_PROXY_NODEPORT_ADDRESSES contains an empty entry"
-
-        case "$trimmed" in
-            */*)
-                ;;
-            *)
-                fail "KUBE_PROXY_NODEPORT_ADDRESSES must be 'primary' or a comma-separated list of CIDRs"
-                ;;
-        esac
-
-        ip_part="${trimmed%/*}"
-        prefix_part="${trimmed#*/}"
-
-        printf '%s' "$prefix_part" | grep -Eq '^[0-9]+$' \
-            || fail "invalid CIDR prefix in KUBE_PROXY_NODEPORT_ADDRESSES: $trimmed"
-
-        case "$ip_part" in
-            *:*)
-                printf '%s' "$prefix_part" | awk '{ exit !($1 >= 0 && $1 <= 128) }' \
-                    || fail "invalid IPv6 CIDR prefix in KUBE_PROXY_NODEPORT_ADDRESSES: $trimmed"
-                ;;
-            *.*.*.*)
-                printf '%s' "$prefix_part" | awk '{ exit !($1 >= 0 && $1 <= 32) }' \
-                    || fail "invalid IPv4 CIDR prefix in KUBE_PROXY_NODEPORT_ADDRESSES: $trimmed"
-                ;;
-            *)
-                fail "invalid CIDR entry in KUBE_PROXY_NODEPORT_ADDRESSES: $trimmed"
-                ;;
-        esac
-    done
-    IFS="$old_ifs"
-}
-
-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
-
-    validate_cidr_list_or_primary "$KUBE_PROXY_NODEPORT_ADDRESSES"
-}
-
-normalize_version() {
-    # strip leading "v"
-    echo "${1#v}"
-}
-
-version_major_minor() {
-    normalize_version "$1" | awk -F. '{ print $1 "." $2 }'
-}
-
-version_eq() {
-    [ "$(normalize_version "$1")" = "$(normalize_version "$2")" ]
-}
-
-version_lt() {
-    [ "$(printf '%s\n%s\n' "$(normalize_version "$1")" "$(normalize_version "$2")" | sort -V | head -n1)" != "$(normalize_version "$2")" ]
-}
-
-version_gt() {
-    [ "$(printf '%s\n%s\n' "$(normalize_version "$1")" "$(normalize_version "$2")" | sort -V | tail -n1)" = "$(normalize_version "$1")" ] \
-        && ! version_eq "$1" "$2"
-}
-
-minor_diff() {
-    a="$(version_major_minor "$1")"
-    b="$(version_major_minor "$2")"
-    a_major="${a%.*}"
-    a_minor="${a#*.}"
-    b_major="${b%.*}"
-    b_minor="${b#*.}"
-
-    [ "$a_major" = "$b_major" ] || fail "major version change unsupported here: $1 -> $2"
-    echo $((b_minor - a_minor))
-}
-
-get_kubeadm_binary_version() {
-    kubeadm version -o short
-}
-
-get_cluster_server_version() {
-    kubectl --kubeconfig /etc/kubernetes/admin.conf version -o yaml \
-        | awk '
-            $1 == "serverVersion:" { in_server=1; next }
-            in_server && $1 == "gitVersion:" { print $2; exit }
-        '
-}
-
-get_api_server_version_from_kubelet_kubeconfig() {
-    kubectl --kubeconfig /etc/kubernetes/kubelet.conf version -o yaml \
-        | awk '
-            $1 == "serverVersion:" { in_server=1; next }
-            in_server && $1 == "gitVersion:" { print $2; exit }
-        '
-}
-
-validate_target_matches_local_binaries() {
-    kubeadm_ver="$(get_kubeadm_binary_version)"
-
-    if ! version_eq "$kubeadm_ver" "$KUBERNETES_VERSION"; then
-        fail "kubeadm binary version ($kubeadm_ver) does not match target KUBERNETES_VERSION ($KUBERNETES_VERSION)"
-    fi
-}
-
-decide_bootstrap_action() {
-    current_version=""
-
-    case "$BOOTSTRAP_MODE" in
-        init)
-            if [ ! -f "$ADMIN_KUBECONFIG" ]; then
-                BOOTSTRAP_ACTION="init"
-                log "selected bootstrap action: $BOOTSTRAP_ACTION"
-                return 0
-            fi
-
-            current_version="$(get_cluster_server_version || true)"
-            [ -n "$current_version" ] || fail "existing control-plane config found, but cluster version could not be determined"
-
-            log "detected existing control-plane version: $current_version"
-
-            if version_eq "$current_version" "$KUBERNETES_VERSION"; then
-                BOOTSTRAP_ACTION="reconcile-control-plane"
-            else
-                BOOTSTRAP_ACTION="upgrade-control-plane"
-            fi
-            ;;
-        join)
-            if [ ! -f "$KUBELET_KUBECONFIG" ]; then
-                BOOTSTRAP_ACTION="join"
-                log "selected bootstrap action: $BOOTSTRAP_ACTION"
-                return 0
-            fi
-
-            current_version="$(get_api_server_version_from_kubelet_kubeconfig || true)"
-            [ -n "$current_version" ] || fail "existing kubelet config found, but cluster version could not be determined"
-
-            log "detected cluster version visible from this node: $current_version"
-
-            if version_eq "$current_version" "$KUBERNETES_VERSION"; then
-                BOOTSTRAP_ACTION="reconcile-node"
-            else
-                BOOTSTRAP_ACTION="upgrade-node"
-            fi
-            ;;
-        *)
-            fail "unsupported BOOTSTRAP_MODE: $BOOTSTRAP_MODE"
-            ;;
-    esac
-
-    log "selected bootstrap action: $BOOTSTRAP_ACTION"
-}
-
-reconcile_control_plane() {
-    log "reconciling existing control-plane node"
-
-    start_kubelet
-    check_kubelet_running
-    wait_for_local_apiserver
-    wait_for_admin_api
-
-    apply_local_node_metadata_if_possible
-    allow_single_node_scheduling
-}
-
-reconcile_node() {
-    log "reconciling existing joined node"
-
-    start_kubelet
-    check_kubelet_running
-}
-
-validate_upgrade_path() {
-    current="$1"
-    target="$2"
-
-    if version_eq "$current" "$target"; then
-        log "cluster is already at target version: $target"
-        return 0
-    fi
-
-    if version_gt "$current" "$target"; then
-        fail "downgrade is not supported: current=$current target=$target"
-    fi
-
-    diff="$(minor_diff "$current" "$target")"
-    case "$diff" in
-        0|1)
-            ;;
-        *)
-            fail "unsupported upgrade path: current=$current target=$target (minor skip too large)"
-            ;;
-    esac
-}
-
-check_upgrade_prereqs() {
-    validate_target_matches_local_binaries
-}
-
-run_kubeadm_upgrade_apply() {
-    current_version="$(get_cluster_server_version || true)"
-    [ -n "$current_version" ] || fail "cannot determine current control-plane version; API server is not reachable"
-
-    log "current control-plane version: $current_version"
-    log "target control-plane version: $KUBERNETES_VERSION"
-
-    validate_upgrade_path "$current_version" "$KUBERNETES_VERSION"
-
-    if version_eq "$current_version" "$KUBERNETES_VERSION"; then
-        log "control-plane already at target version; skipping kubeadm upgrade apply"
-        return 0
-    fi
-
-    log "running kubeadm upgrade plan..."
-    kubeadm upgrade plan "$KUBERNETES_VERSION"
-
-    log "running kubeadm upgrade apply..."
-    kubeadm upgrade apply -y "$KUBERNETES_VERSION"
-}
-
-run_kubeadm_upgrade_node() {
-    cluster_version="$(get_api_server_version_from_kubelet_kubeconfig)"
-    log "cluster/control-plane version visible from this node: $cluster_version"
-    log "target node version: $KUBERNETES_VERSION"
-
-    if ! version_eq "$cluster_version" "$KUBERNETES_VERSION"; then
-        fail "control-plane version ($cluster_version) does not match target ($KUBERNETES_VERSION); upgrade control-plane first"
-    fi
-
-    log "running kubeadm upgrade node..."
-    kubeadm upgrade node
-}
-
-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_kube_proxy_config_block() {
-    if [ -z "${KUBE_PROXY_NODEPORT_ADDRESSES:-}" ]; then
-        return 0
-    fi
-
-    if [ "$KUBE_PROXY_NODEPORT_ADDRESSES" = "primary" ]; then
-        cat <<EOF
----
-apiVersion: kubeproxy.config.k8s.io/v1alpha1
-kind: KubeProxyConfiguration
-nodePortAddresses:
-  - primary
-EOF
-        return 0
-    fi
-
-    echo "---"
-    echo "apiVersion: kubeproxy.config.k8s.io/v1alpha1"
-    echo "kind: KubeProxyConfiguration"
-    echo "nodePortAddresses:"
-
-    old_ifs="$IFS"
-    IFS=','
-    for item in $KUBE_PROXY_NODEPORT_ADDRESSES; do
-        trimmed="$(printf '%s' "$item" | sed 's/^ *//;s/ *$//')"
-        [ -n "$trimmed" ] && printf '  - "%s"\n' "$trimmed"
-    done
-    IFS="$old_ifs"
-}
-
-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
-
-    generate_kube_proxy_config_block >> "$KUBEADM_CONFIG_OUT"
-}
-
-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
-}
-
-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
-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.
-- kube-proxy nodePortAddresses is set to: ${KUBE_PROXY_NODEPORT_ADDRESSES:-<unset>}
-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
-
-    install_cni_if_requested
-    start_crio
-    check_crio_running
-
-    wait_for_existing_cluster_if_needed
-    decide_bootstrap_action
-
-    case "$BOOTSTRAP_ACTION" in
-        init)
-            check_required_images
-            generate_kubeadm_config
-            run_kubeadm_init
-            restart_kubelet
-            apply_local_node_metadata_if_possible
-            allow_single_node_scheduling
-            ;;
-        reconcile-control-plane)
-            reconcile_control_plane
-            ;;
-        upgrade-control-plane)
-            check_upgrade_prereqs
-            check_required_images
-            generate_kubeadm_config
-            run_kubeadm_upgrade_apply
-            restart_kubelet
-            apply_local_node_metadata_if_possible
-            allow_single_node_scheduling
-            ;;
-        join)
-            run_kubeadm_join
-            ;;
-        reconcile-node)
-            reconcile_node
-            ;;
-        upgrade-node)
-            check_upgrade_prereqs
-            run_kubeadm_upgrade_node
-            restart_kubelet
-            ;;
-        *)
-            fail "unsupported BOOTSTRAP_ACTION: $BOOTSTRAP_ACTION"
-            ;;
-    esac
-
-    print_next_steps
-}
-
-main "$@"
diff --git a/clitools/pkg/cmd/initcmd/init.go b/clitools/pkg/cmd/initcmd/init.go
index 562105e..60d9ea2 100644
--- a/clitools/pkg/cmd/initcmd/init.go
+++ b/clitools/pkg/cmd/initcmd/init.go
@@ -1,7 +1,9 @@
 package initcmd
 
 import (
+	"bufio"
 	"fmt"
+	"os"
 	"sort"
 	"strconv"
 	"strings"
@@ -12,18 +14,28 @@ import (
 
 	"undecided.project/monok8s/pkg/bootstrap"
 	"undecided.project/monok8s/pkg/config"
+
+	types "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
+	"undecided.project/monok8s/pkg/templates"
 )
 
 func NewCmdInit(_ *genericclioptions.ConfigFlags) *cobra.Command {
 	var configPath string
+	var envFile string
 
 	cmd := &cobra.Command{
-		Use:   "init [list|STEPSEL]",
-		Short: "Start the bootstrap process for this node",
+		Use:   "init [list|STEPSEL] [--config path | --env-file path]",
+		Short: "Bootstrap this node (from config file or env file)",
 		Long: `Run the node bootstrap process.
 
+You can provide configuration in two ways:
+
+  --config PATH      Load MonoKSConfig YAML
+  --env-file PATH    Load MKS_* variables from env file and render config
+
 STEPSEL allows running specific steps instead of the full sequence.
-It supports:
+
+Supported formats:
 
   3           Run step 3
   1-3         Run steps 1 through 3
@@ -33,23 +45,54 @@ It supports:
   9-10,15     Combine ranges and individual steps
 `,
 		Example: `
-  ctl init
+  # Run full bootstrap using config file
+  ctl init --config /etc/monok8s/config.yaml
+
+  # Run full bootstrap using env file
+  ctl init --env-file /opt/monok8s/config/cluster.env
+
+  # List steps
   ctl init list
-  ctl init 1-3
-  ctl init -3
-  ctl init 3-
-  ctl init 1,3,5
-  ctl init 9-10,15
+
+  # Run selected steps
+  ctl init 1-3 --env-file /opt/monok8s/config/cluster.env
+  ctl init 3- --config /etc/monok8s/config.yaml
 `,
 		Args: cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			path, err := (config.Loader{}).ResolvePath(configPath)
-			if err != nil {
-				return err
+			if strings.TrimSpace(configPath) != "" && strings.TrimSpace(envFile) != "" {
+				return fmt.Errorf("--config and --env-file are mutually exclusive")
 			}
-			cfg, err := (config.Loader{}).Load(path)
-			if err != nil {
-				return err
+
+			if strings.TrimSpace(envFile) != "" {
+				if err := loadEnvFile(envFile); err != nil {
+					return fmt.Errorf("load env file %q: %w", envFile, err)
+				}
+			}
+
+			var cfg *types.MonoKSConfig // or value, depending on your API
+
+			switch {
+			case strings.TrimSpace(envFile) != "":
+				if err := loadEnvFile(envFile); err != nil {
+					return fmt.Errorf("load env file %q: %w", envFile, err)
+				}
+				vals := templates.LoadTemplateValuesFromEnv()
+				rendered := templates.DefaultMonoKSConfig(vals)
+				cfg = &rendered
+
+			default:
+				path, err := (config.Loader{}).ResolvePath(configPath)
+				if err != nil {
+					return err
+				}
+
+				loaded, err := (config.Loader{}).Load(path)
+				if err != nil {
+					return err
+				}
+				cfg = loaded
+				klog.InfoS("starting init", "config", path, "node", cfg.Spec.NodeName, "envFile", envFile)
 			}
 
 			runner := bootstrap.NewRunner(cfg)
@@ -59,18 +102,15 @@ It supports:
 
 				fmt.Fprintln(cmd.OutOrStdout(), "Showing current bootstrap sequence")
 
-				// width = number of digits of max step number
 				width := len(fmt.Sprintf("%d", len(steps)))
 
 				for i, s := range steps {
 					fmt.Fprintf(cmd.OutOrStdout(), "\n  %*d. %s\n", width, i+1, s.Name)
 					fmt.Fprintf(cmd.OutOrStdout(), "        %s\n", s.Desc)
 				}
-				return err
+				return nil
 			}
 
-			klog.InfoS("starting init", "config", path, "node", cfg.Spec.NodeName)
-
 			if len(args) == 0 {
 				return runner.Init(cmd.Context())
 			}
@@ -87,9 +127,60 @@ It supports:
 	}
 
 	cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to MonoKSConfig yaml")
+	cmd.Flags().StringVar(&envFile, "env-file", "", "path to env file containing MKS_* variables")
+
 	return cmd
 }
 
+func loadEnvFile(path string) error {
+	f, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	scanner := bufio.NewScanner(f)
+	lineNum := 0
+
+	for scanner.Scan() {
+		lineNum++
+		line := strings.TrimSpace(scanner.Text())
+
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		key, val, ok := strings.Cut(line, "=")
+		if !ok {
+			return fmt.Errorf("line %d: expected KEY=VALUE", lineNum)
+		}
+
+		key = strings.TrimSpace(key)
+		val = strings.TrimSpace(val)
+
+		if key == "" {
+			return fmt.Errorf("line %d: empty variable name", lineNum)
+		}
+
+		// Remove matching single or double quotes around the whole value.
+		if len(val) >= 2 {
+			if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
+				val = val[1 : len(val)-1]
+			}
+		}
+
+		if err := os.Setenv(key, val); err != nil {
+			return fmt.Errorf("line %d: set %q: %w", lineNum, key, err)
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func parseStepSelection(raw string, max int) (bootstrap.StepSelection, error) {
 	raw = strings.TrimSpace(raw)
 	if raw == "" {
diff --git a/clitools/pkg/render/monoks.go b/clitools/pkg/render/monoks.go
index c4f7645..845f5b2 100644
--- a/clitools/pkg/render/monoks.go
+++ b/clitools/pkg/render/monoks.go
@@ -7,11 +7,12 @@ import (
 	"k8s.io/apimachinery/pkg/runtime/serializer/json"
 
 	"undecided.project/monok8s/pkg/scheme"
-	tmpl "undecided.project/monok8s/pkg/templates"
+	"undecided.project/monok8s/pkg/templates"
 )
 
 func RenderMonoKSConfig() (string, error) {
-	cfg := tmpl.DefaultMonoKSConfig()
+	vals := templates.LoadTemplateValuesFromEnv()
+	cfg := templates.DefaultMonoKSConfig(vals)
 
 	s := runtime.NewScheme()
 	if err := scheme.AddToScheme(s); err != nil {
@@ -19,9 +20,7 @@ func RenderMonoKSConfig() (string, error) {
 	}
 
 	serializer := json.NewYAMLSerializer(
-		json.DefaultMetaFactory,
-		s,
-		s,
+		json.DefaultMetaFactory, s, s,
 	)
 
 	var buf bytes.Buffer
@@ -33,7 +32,8 @@ func RenderMonoKSConfig() (string, error) {
 }
 
 func RenderOSUpgrade() (string, error) {
-	cfg := tmpl.DefaultOSUpgrade()
+	vals := templates.LoadTemplateValuesFromEnv()
+	cfg := templates.DefaultOSUpgrade(vals)
 
 	s := runtime.NewScheme()
 	if err := scheme.AddToScheme(s); err != nil {
@@ -41,9 +41,7 @@ func RenderOSUpgrade() (string, error) {
 	}
 
 	serializer := json.NewYAMLSerializer(
-		json.DefaultMetaFactory,
-		s,
-		s,
+		json.DefaultMetaFactory, s, s,
 	)
 
 	var buf bytes.Buffer
diff --git a/clitools/pkg/templates/templates.go b/clitools/pkg/templates/templates.go
index 7598a09..48e8514 100644
--- a/clitools/pkg/templates/templates.go
+++ b/clitools/pkg/templates/templates.go
@@ -1,26 +1,13 @@
 package templates
 
 import (
-	"os"
+	"strings"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	types "undecided.project/monok8s/pkg/apis/monok8s/v1alpha1"
-	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"
-
-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 {
+func DefaultMonoKSConfig(v TemplateValues) types.MonoKSConfig {
 	return types.MonoKSConfig{
 		TypeMeta: metav1.TypeMeta{
 			APIVersion: "monok8s.io/v1alpha1",
@@ -31,66 +18,51 @@ func DefaultMonoKSConfig() types.MonoKSConfig {
 			Namespace: "kube-system",
 		},
 		Spec: types.MonoKSConfigSpec{
-			KubernetesVersion: buildinfo.Version,
-			NodeName:          ValHostname,
+			KubernetesVersion: v.KubernetesVersion,
+			NodeName:          firstNonEmpty(v.NodeName, v.Hostname),
 
-			ClusterRole:      "control-plane",
-			InitControlPlane: true,
+			ClusterRole:      clusterRoleFromTemplateValues(v),
+			InitControlPlane: initControlPlaneFromTemplateValues(v),
 
-			ClusterName:   "monok8s",
-			ClusterDomain: "cluster.local",
+			ClusterName:   v.ClusterName,
+			ClusterDomain: v.ClusterDomain,
 
-			PodSubnet:     "10.244.0.0/16",
-			ServiceSubnet: "10.96.0.0/12",
+			PodSubnet:     v.PodSubnet,
+			ServiceSubnet: v.ServiceSubnet,
 
-			APIServerAdvertiseAddress: "10.0.0.10",
-			APIServerEndpoint:         ValAPIServerEndPoint,
+			APIServerAdvertiseAddress: v.APIServerAdvertiseAddress,
+			APIServerEndpoint:         v.APIServerEndpoint,
 
-			// Fake token and hash for placeholder purpose
-			BootstrapToken:           ValBootstrapToken,
-			DiscoveryTokenCACertHash: ValDiscoveryTokenCACertHash,
+			BootstrapToken:           v.BootstrapToken,
+			DiscoveryTokenCACertHash: v.DiscoveryTokenCACertHash,
 
-			ContainerRuntimeEndpoint: "unix:///var/run/crio/crio.sock",
+			ContainerRuntimeEndpoint: v.ContainerRuntimeEndpoint,
+			CNIPlugin:                v.CNIPlugin,
 
-			CNIPlugin: "default",
-
-			AllowSchedulingOnControlPlane: true,
-			SkipImageCheck:                false,
+			AllowSchedulingOnControlPlane: v.AllowSchedulingOnControlPlane,
+			SkipImageCheck:                v.SkipImageCheck,
 
 			KubeProxyNodePortAddresses: []string{
 				"primary",
 			},
 
-			SubjectAltNames: []string{
-				"10.0.0.10", "localhost", ValHostname,
-			},
-
-			NodeLabels: map[string]string{
-				"monok8s.io/label": "value",
-			},
-
-			NodeAnnotations: map[string]string{
-				"monok8s.io/annotation": "value",
-			},
+			SubjectAltNames: copyStringSlice(v.SubjectAltNames),
+			NodeLabels:      copyStringMap(v.NodeLabels),
+			NodeAnnotations: copyStringMap(v.NodeAnnotations),
 
 			Network: types.NetworkSpec{
-				Hostname:        "monok8s-worker-1",
-				ManagementIface: "eth1",
-				ManagementCIDR:  "10.0.0.10/24",
-				ManagementGW:    "10.0.0.1",
-				DNSNameservers: []string{
-					"1.1.1.1",
-					"8.8.8.8",
-				},
-				DNSSearchDomains: []string{
-					"lan",
-				},
+				Hostname:         firstNonEmpty(v.Hostname, v.NodeName),
+				ManagementIface:  v.MgmtIface,
+				ManagementCIDR:   v.MgmtAddress,
+				ManagementGW:     v.MgmtGateway,
+				DNSNameservers:   copyStringSlice(v.DNSNameservers),
+				DNSSearchDomains: copyStringSlice(v.DNSSearchDomains),
 			},
 		},
 	}
 }
 
-func DefaultOSUpgrade() types.OSUpgrade {
+func DefaultOSUpgrade(v TemplateValues) types.OSUpgrade {
 	return types.OSUpgrade{
 		TypeMeta: metav1.TypeMeta{
 			APIVersion: "monok8s.io/v1alpha1",
@@ -105,9 +77,56 @@ func DefaultOSUpgrade() types.OSUpgrade {
 			ImageURL:        "https://example.invalid/images/monok8s-v0.0.1.img.zst",
 			TargetPartition: "B",
 			NodeSelector: []string{
-				ValHostname,
+				firstNonEmpty(v.NodeName, v.Hostname),
 			},
 			Force: false,
 		},
 	}
 }
+
+func clusterRoleFromTemplateValues(v TemplateValues) string {
+	switch strings.ToLower(strings.TrimSpace(v.BootstrapMode)) {
+	case "init":
+		return "control-plane"
+	case "join":
+		if strings.EqualFold(strings.TrimSpace(v.JoinKind), "control-plane") {
+			return "control-plane"
+		}
+		return "worker"
+	default:
+		return "control-plane"
+	}
+}
+
+func initControlPlaneFromTemplateValues(v TemplateValues) bool {
+	return strings.EqualFold(strings.TrimSpace(v.BootstrapMode), "init")
+}
+
+func firstNonEmpty(xs ...string) string {
+	for _, x := range xs {
+		if strings.TrimSpace(x) != "" {
+			return strings.TrimSpace(x)
+		}
+	}
+	return ""
+}
+
+func copyStringSlice(in []string) []string {
+	if len(in) == 0 {
+		return nil
+	}
+	out := make([]string, len(in))
+	copy(out, in)
+	return out
+}
+
+func copyStringMap(in map[string]string) map[string]string {
+	if len(in) == 0 {
+		return nil
+	}
+	out := make(map[string]string, len(in))
+	for k, v := range in {
+		out[k] = v
+	}
+	return out
+}
\ No newline at end of file
diff --git a/clitools/pkg/templates/values.go b/clitools/pkg/templates/values.go
new file mode 100644
index 0000000..efd5072
--- /dev/null
+++ b/clitools/pkg/templates/values.go
@@ -0,0 +1,204 @@
+package templates
+
+import (
+	"os"
+	"strings"
+
+	buildinfo "undecided.project/monok8s/pkg/buildinfo"
+)
+
+type TemplateValues struct {
+	Hostname                  string
+	NodeName                  string
+	KubernetesVersion         string
+	ClusterName               string
+	ClusterDomain             string
+	PodSubnet                 string
+	ServiceSubnet             string
+	APIServerAdvertiseAddress string
+	APIServerEndpoint         string
+	BootstrapToken            string
+	DiscoveryTokenCACertHash  string
+	ControlPlaneCertKey       string
+	ContainerRuntimeEndpoint  string
+	CNIPlugin                 string
+
+	BootstrapMode string // init, join
+	JoinKind      string // worker, control-plane
+
+	AllowSchedulingOnControlPlane bool
+	SkipImageCheck                bool
+
+	MgmtIface        string
+	MgmtAddress      string
+	MgmtGateway      string
+	DNSNameservers   []string
+	DNSSearchDomains []string
+
+	SubjectAltNames []string
+	NodeLabels      map[string]string
+	NodeAnnotations map[string]string
+}
+
+func defaultTemplateValues() TemplateValues {
+	return TemplateValues{
+		Hostname:                  "monok8s-master-1",
+		NodeName:                  "monok8s-master-1",
+		KubernetesVersion:         buildinfo.Version,
+		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",
+		BootstrapToken:            "abcd12.ef3456789abcdef0",
+		DiscoveryTokenCACertHash:  "sha256:9f1c2b3a4d5e6f7890abc1234567890abcdef1234567890abcdef1234567890ab",
+		ControlPlaneCertKey:       "",
+		ContainerRuntimeEndpoint:  "unix:///var/run/crio/crio.sock",
+		CNIPlugin:                 "default",
+
+		BootstrapMode: "init",
+		JoinKind:      "worker",
+
+		AllowSchedulingOnControlPlane: true,
+		SkipImageCheck:                false,
+
+		MgmtIface:   "eth1",
+		MgmtAddress: "10.0.0.10/24",
+		MgmtGateway: "10.0.0.1",
+
+		DNSNameservers:   []string{"1.1.1.1", "8.8.8.8"},
+		DNSSearchDomains: []string{"lan"},
+
+		SubjectAltNames: []string{"10.0.0.10", "localhost", "monok8s-master-1"},
+
+		NodeLabels: map[string]string{
+			"monok8s.io/label": "value",
+		},
+		NodeAnnotations: map[string]string{
+			"monok8s.io/annotation": "value",
+		},
+	}
+}
+
+func LoadTemplateValuesFromEnv() TemplateValues {
+	v := defaultTemplateValues()
+
+	v.Hostname = getenvDefault("MKS_HOSTNAME", v.Hostname)
+	v.NodeName = getenvDefault("MKS_NODE_NAME", v.Hostname)
+
+	v.KubernetesVersion = getenvDefault("MKS_KUBERNETES_VERSION", v.KubernetesVersion)
+	v.ClusterName = getenvDefault("MKS_CLUSTER_NAME", v.ClusterName)
+	v.ClusterDomain = getenvDefault("MKS_CLUSTER_DOMAIN", v.ClusterDomain)
+	v.PodSubnet = getenvDefault("MKS_POD_SUBNET", v.PodSubnet)
+	v.ServiceSubnet = getenvDefault("MKS_SERVICE_SUBNET", v.ServiceSubnet)
+
+	v.APIServerAdvertiseAddress = getenvDefault("MKS_APISERVER_ADVERTISE_ADDRESS", v.APIServerAdvertiseAddress)
+	v.APIServerEndpoint = getenvDefault("MKS_API_SERVER_ENDPOINT", v.APIServerEndpoint)
+
+	v.BootstrapToken = getenvDefault("MKS_BOOTSTRAP_TOKEN", v.BootstrapToken)
+	v.DiscoveryTokenCACertHash = getenvDefault("MKS_DISCOVERY_TOKEN_CA_CERT_HASH", v.DiscoveryTokenCACertHash)
+	v.ControlPlaneCertKey = getenvDefault("MKS_CONTROL_PLANE_CERT_KEY", v.ControlPlaneCertKey)
+
+	v.ContainerRuntimeEndpoint = getenvDefault("MKS_CONTAINER_RUNTIME_ENDPOINT", v.ContainerRuntimeEndpoint)
+	v.CNIPlugin = getenvDefault("MKS_CNI_PLUGIN", v.CNIPlugin)
+
+	v.BootstrapMode = getenvDefault("MKS_BOOTSTRAP_MODE", v.BootstrapMode)
+	v.JoinKind = getenvDefault("MKS_JOIN_KIND", v.JoinKind)
+
+	v.AllowSchedulingOnControlPlane = getenvBoolDefault("MKS_ALLOW_SCHEDULING_ON_CONTROL_PLANE", v.AllowSchedulingOnControlPlane)
+	v.SkipImageCheck = getenvBoolDefault("MKS_SKIP_IMAGE_CHECK", v.SkipImageCheck)
+
+	v.MgmtIface = getenvDefault("MKS_MGMT_IFACE", v.MgmtIface)
+	v.MgmtAddress = getenvDefault("MKS_MGMT_ADDRESS", v.MgmtAddress)
+	v.MgmtGateway = getenvDefault("MKS_MGMT_GATEWAY", v.MgmtGateway)
+
+	if xs := splitWhitespaceList(os.Getenv("MKS_DNS_NAMESERVERS")); len(xs) > 0 {
+		v.DNSNameservers = xs
+	}
+	if xs := splitWhitespaceList(os.Getenv("MKS_DNS_SEARCH_DOMAINS")); len(xs) > 0 {
+		v.DNSSearchDomains = xs
+	}
+	if xs := splitCommaList(os.Getenv("MKS_SANS")); len(xs) > 0 {
+		v.SubjectAltNames = xs
+	}
+	if m := parseKeyValueMap(os.Getenv("MKS_NODE_LABELS")); len(m) > 0 {
+		v.NodeLabels = m
+	}
+	if m := parseKeyValueMap(os.Getenv("MKS_NODE_ANNOTATIONS")); len(m) > 0 {
+		v.NodeAnnotations = m
+	}
+
+	return v
+}
+
+func getenvDefault(key, def string) string {
+	s := strings.TrimSpace(os.Getenv(key))
+	if s == "" {
+		return def
+	}
+	return s
+}
+
+func getenvBoolDefault(key string, def bool) bool {
+	s := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
+	if s == "" {
+		return def
+	}
+	switch s {
+	case "1", "true", "yes", "y", "on":
+		return true
+	case "0", "false", "no", "n", "off":
+		return false
+	default:
+		return def
+	}
+}
+
+func splitCommaList(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+	parts := strings.Split(s, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+func splitWhitespaceList(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+	return strings.Fields(s)
+}
+
+func parseKeyValueMap(s string) map[string]string {
+	out := map[string]string{}
+	if strings.TrimSpace(s) == "" {
+		return out
+	}
+
+	for _, item := range strings.Split(s, ",") {
+		item = strings.TrimSpace(item)
+		if item == "" {
+			continue
+		}
+		k, val, ok := strings.Cut(item, "=")
+		if !ok {
+			continue
+		}
+		k = strings.TrimSpace(k)
+		val = strings.TrimSpace(val)
+		if k == "" {
+			continue
+		}
+		out[k] = val
+	}
+
+	return out
+}
diff --git a/configs/cluster.env.default b/configs/cluster.env.default
index efc09b5..188c00a 100644
--- a/configs/cluster.env.default
+++ b/configs/cluster.env.default
@@ -1,44 +1,58 @@
-# Required
-KUBERNETES_VERSION=v1.35.3
-NODE_NAME=monok8s-master
-APISERVER_ADVERTISE_ADDRESS=192.168.1.50
+## Host config
+MKS_HOSTNAME=monok8s-master
+MKS_NODE_NAME=monok8s-master
+
+# RJ45 Ports from left to right (interface name inside the OS)
+#   - eth1, eth2, eth0
+MKS_MGMT_IFACE=eth1
+MKS_MGMT_ADDRESS=10.0.0.14/24
+MKS_MGMT_GATEWAY=10.0.0.1
+
+# Space-separated real upstream DNS servers.
+# Do NOT put the cluster DNS Service IP here.
+MKS_DNS_NAMESERVERS="10.0.0.1 1.1.1.1 1.0.0.1"
+
+# Optional. Space-separated.
+MKS_DNS_SEARCH_DOMAINS="lan"
+
+## k8s config
+MKS_KUBERNETES_VERSION=v1.35.3
+MKS_APISERVER_ADVERTISE_ADDRESS=10.0.0.14
 
 # Optional but strongly recommended
-CLUSTER_NAME=monok8s
-POD_SUBNET=10.244.0.0/16
-SERVICE_SUBNET=10.96.0.0/12
-CLUSTER_DOMAIN=cluster.local
+MKS_CLUSTER_NAME=monok8s
+MKS_POD_SUBNET=10.244.0.0/16
+MKS_SERVICE_SUBNET=10.96.0.0/12
+MKS_CLUSTER_DOMAIN=cluster.local
 
 # Bootstrap mode: init, join
-BOOTSTRAP_MODE=init
+MKS_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=
+MKS_JOIN_KIND=worker
+MKS_API_SERVER_ENDPOINT=
+MKS_BOOTSTRAP_TOKEN=
+MKS_DISCOVERY_TOKEN_CA_CERT_HASH=
+MKS_CONTROL_PLANE_CERT_KEY=
 
-# none: Install manually
-# bridge: CRIO's default bridge CNI
-CNI_PLUGIN=none
+# none: install manually
+# default|bridge: CRI-O default bridge CNI
+MKS_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
+# Comma-separated key=value pairs
+MKS_NODE_LABELS=topology.kubernetes.io/zone=lab,node.kubernetes.io/instance-type=mono-gateway
+MKS_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
-
-# Single-node mode: allow workloads on the control plane
-ALLOW_SCHEDULING_ON_CONTROL_PLANE=yes
+MKS_SANS=127.0.0.1,localhost,monok8s-master,10.0.0.14
 
 # CRI-O socket
-CONTAINER_RUNTIME_ENDPOINT=unix:///var/run/crio/crio.sock
+MKS_CONTAINER_RUNTIME_ENDPOINT=unix:///var/run/crio/crio.sock
 
-# Usually leave this alone
-KUBECONFIG_USER_HOME=/root
+# Optional
+MKS_ALLOW_SCHEDULING_ON_CONTROL_PLANE=true
 
 # Emergency override only
-SKIP_IMAGE_CHECK=no
+MKS_SKIP_IMAGE_CHECK=no
\ No newline at end of file
diff --git a/configs/node.env.default b/configs/node.env.default
deleted file mode 100644
index 3fec91b..0000000
--- a/configs/node.env.default
+++ /dev/null
@@ -1,14 +0,0 @@
-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
-
-# Space-separated real upstream DNS servers.
-# Do NOT put the cluster DNS Service IP here.
-DNS_NAMESERVERS="10.0.0.1 1.1.1.1 1.0.0.1"
-
-# Optional
-DNS_SEARCH_DOMAINS="lan"
diff --git a/makefile b/makefile
index f7d26e2..04f8179 100644
--- a/makefile
+++ b/makefile
@@ -238,18 +238,7 @@ check-functions:
 	@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 ------------------------------------------------------------
+# ---- config targets ------------------------------------------------------------
 
 cluster-config: $(CLUSTER_ENV_DEFAULT) $(SCRIPTS_DIR)/merge-env.sh | $(OUT_DIR)
 	sh $(SCRIPTS_DIR)/merge-env.sh $(CLUSTER_ENV_DEFAULT) $(CLUSTER_ENV)