Join profiles
An SSHJoinProfile is the pluggable answer to "how does a host become a
node here". The provider knows nothing about kubeadm, nodeadm, k0s or SKS —
profiles do.
Verified execution. For hosts in
execMode: Verified, profile scripts are signed offline and verified before they run (controller and host both check). Those scripts must be template-free — all variability arrives asKPSSH_*env, never{{ … }}. See Verified execution.
Lifecycle contract
| script | when | must be | typical duration |
|---|---|---|---|
install |
first claim of a host, or when <name>@<version> ≠ status.installedProfile |
idempotent, heavy OK | 60–90 s |
join |
every claim | idempotent, fast | seconds (warm) |
leave |
every release | idempotent, fast, must not destroy installed state | seconds |
uninstall |
never automatically — manual/repair | idempotent | any |
Rules the engine enforces or assumes:
- Idempotency is load-bearing. The controller may re-run any phase after a crash/leader change. Scripts must converge, not append.
leavereturns the host to a state wherejoinalone reconnects it (the warm-pool invariant). Cleaning caches is fine; removing binaries belongs inuninstall.leavemust survive a reboot. A rebooted warm-pool host must stay out of the cluster:systemctl disable --nowkubelet and containerd — a merely-stopped-but-enabled kubelet with warm credentials rejoins on boot, silently re-opening the billing window.joinre-enables both. The controller's zombie guard catches violations (probe detects a kubelet on an unclaimed host and forces a leave), but the profile is the first line.- Exit code ≠ 0 fails the phase: a failed
joinreleases the claim and marks the hostUnhealthy; karpenter retries elsewhere. - In Raw mode (the default) scripts run as
sudo bash -s(or plainbash -sforuser: root) withset -euo pipefailprepended and the environment exported in a preamble. In Verified mode neither happens: the shim runs the signed bytes exactly as signed — prepending anything would break the very signature it just checked — and the environment arrives as validatedKPSSH_*shim params instead of a preamble. A verified script must carry its ownset -euo pipefail. Without it a failing command does not abort the script, the phase exits 0, and the host is marked joined when it is not. - Scripts are ≤ 256 KiB each;
timeouts.*are Go durations restricted to^([0-9]+(s|m|h))+$(90s,10m,1h30m— not1.5h, not500ms).
The leave contract: empty render context
leave renders with an empty template context. It gets no .Vars, no
.Secrets, no endpoint, no token — and no KPSSH_VAR_* / KPSSH_SECRET_*
environment either.
That is not an oversight. Two paths run leave with no node class in hand:
- the release path resolves the profile from the host's
status.installedProfilemarker, not from a NodeClass; - the zombie guard has no NodeClaim at all — it disconnects a host that rejoined on its own.
A leave reaching for {{ .Vars.clusterName }} therefore fails at exactly the
moment a host must be disconnected — and the guard retries it forever while the
node keeps running (and, on EKS Hybrid, keeps billing).
The node class controller rejects such profiles up front: every SSHNodeClass
referencing them goes Ready=False with reason ProfileInvalid, and
karpenter will not launch against it.
kubectl get snc -o custom-columns=NAME:.metadata.name,\
READY:'.status.conditions[?(@.type=="Ready")].status',\
REASON:'.status.conditions[?(@.type=="Ready")].reason'
The only context leave does get is the host's own addressing:
KPSSH_HOST_ADDRESS and KPSSH_NODE_ADDRESS. Everything a leave needs to
know beyond that must be discoverable on the host (systemctl is-enabled,
test -f, files written by join) — which is what an idempotent leave should
be doing anyway.
Environment contract
| variable | phases | content |
|---|---|---|
KPSSH_CLUSTER_ENDPOINT |
install, join | API server URL — NodeClass cluster.endpoint override, else kube-public/cluster-info |
KPSSH_CLUSTER_CA_B64 |
install, join | base64 cluster CA (same source) |
KPSSH_BOOTSTRAP_TOKEN |
join | fresh bootstrap token, TTL 1 h, group system:bootstrappers:kpssh. Recorded on the host (status.bootstrapTokenID) and deleted once the node registers |
KPSSH_NODE_NAME |
install, join | node name = NodeClaim name (use for --hostname-override in Static mode) |
KPSSH_PROVIDER_ID |
install, join | kpssh://<ns>/<host> in Static mode; empty in Adopt mode |
KPSSH_HOST_ADDRESS |
install, join, leave | the host's SSH address (spec.address) — the machine the script is running on, as the controller addresses it |
KPSSH_NODE_ADDRESS |
install, join, leave | IP the node advertises (--node-ip), from spec.nodeAddress, else spec.address |
KPSSH_NODE_LABELS |
install, join | comma-separated k=v from the NodeClaim (karpenter-requested labels), filtered to those a kubelet is allowed to self-set. The rest still land on the Node — core syncs all NodeClaim labels at registration |
KPSSH_TAINTS |
install, join | --register-with-taints value: always led by karpenter.sh/unregistered:NoExecute (karpenter's registration contract — core removes it after syncing labels/taints), then the NodeClaim taints (key=value:Effect, comma-separated). Profiles MUST pass it to the kubelet |
KPSSH_VAR_<KEY> |
install, join | every SSHNodeClass.spec.vars entry |
KPSSH_SECRET_<KEY> |
install, join | every joinSecretRef Secret data key, uppercased, - and . → _ |
leave sees only the two address variables (above); the other fixed names are
exported but empty, and KPSSH_VAR_*/KPSSH_SECRET_* are absent entirely.
uninstall is never executed by the controller.
Everything is also available as Go-template data at render time; prefer env in
scripts (works under bash -s, easier to test by hand).
Shipped profiles
tls-bootstrap (examples/profile-tls-bootstrap.yaml)
Generic kubelet TLS bootstrapping against any conformant control plane.
Verified: kind (the in-repo e2e — join/leave run verbatim; install is
stubbed there, because a kindest/node host already has kubelet), k0s,
Exoscale SKS (unofficial but working).
install: containerd + kubelet + CNI plugins for the host's archjoin: writes bootstrap kubeconfig fromKPSSH_BOOTSTRAP_TOKEN+ endpoint/CA, starts kubelet with--provider-id,--node-ip,--hostname-overrideleave: disables + stops kubelet and containerd, kills workload shims, wipes membership state + CNI devices, keeps binaries- vars:
k8sMinor(required, must match the control plane),clusterDNS(default10.96.0.10),serverTLSBootstrap("true"when a kubelet-serving CSR approver runs — needed forkubectl top/metrics-server with kubelet CA verification)
Cluster-side requirements: bind group system:bootstrappers:kpssh to
system:node-bootstrapper + client-CSR auto-approval —
examples/bootstrap-rbac.yaml is the
copy-paste version.
nodeadm-ssm (examples/profile-nodeadm-ssm.yaml)
EKS Hybrid Nodes via nodeadm with the SSM credential provider. Verified live
on a real EKS 1.34 cluster. Implements the warm/cold split that makes the
billing model work:
flowchart TD
J[join called]:::prov --> Q{"SSM registration +<br>nodeConfig.yaml present?"}:::dim
Q -->|yes — warm| W["systemctl enable --now containerd kubelet<br>seconds · same mi-* · consumes nothing"]:::pool
Q -->|no — cold| C{"KPSSH_SECRET_ACTIVATIONID/<br>ACTIVATIONCODE set?"}:::dim
C -->|yes| N["render nodeConfig.yaml<br>nodeadm install + init<br>new mi-* · consumes 1 registration"]:::pay
C -->|no| F[fail: activation required]:::warn
classDef pool fill:#e3efec,stroke:#1d6e5e,color:#1c2422
classDef pay fill:#f6ead9,stroke:#b35a00,color:#1c2422
classDef prov fill:#e7ecf9,stroke:#274bb0,color:#1c2422
classDef warn fill:#f6e4e2,stroke:#a83a2e,color:#1c2422
classDef dim fill:#f6f8f6,stroke:#5c6b66,color:#1c2422
leave:systemctl disable --now kubelet containerdand nothing else — the SSM registration is the durable node identity; destroying it would turn every rejoin cold. Disabling (not just stopping) keeps a rebooted host out of the clusteruninstall:nodeadm uninstall(kills registration; next join needs a valid activation and mints a newmi-*)- vars:
clusterName,region,k8sVersion - pair with
providerIDSource: Adopt— nodeadm owns the providerID (eks-hybrid:///…/mi-*); the provider adopts it by InternalIP match
Activation credentials come from joinSecretRef — the provider stays
AWS-free and only reads the Secret at join time; producing and rotating it is
outside its scope.
Do not xtrace secrets: set -x prints commands after expansion, so a
: "${KPSSH_SECRET_ACTIVATIONID:?}" check or a heredoc that interpolates
KPSSH_SECRET_* echoes the value to stderr, which the controller relays into
its logs. The shipped join runs the activation checks and the nodeConfig.yaml
heredoc without -x and switches tracing on only afterwards. Follow the same
rule in your own profiles for every KPSSH_SECRET_* value.
sbc (examples/sbc/profile-sbc.yaml)
tls-bootstrap for Debian-family single-board computers (Raspberry Pi OS,
Armbian, Ubuntu for Pi): memory-cgroup guard, skip-apt-when-current, Debian's
containerd CNI path, kubeadm drop-in ordering. Verified on a Raspberry Pi 4 —
the walkthrough, the board-prep half, and the measured join/leave numbers are
in SBC fleets.
Writing your own profile
- Start from
tls-bootstrapand keep the phase semantics table above next to you. - Make every script converge:
command -v,test -f,systemctl is-activeguards before every mutation. - Put mechanism config into
vars, credentials intojoinSecretRef— never inline secrets into scripts (they land in the CR, in etcd, inkubectl get -o yaml). - Decide identity: if your mechanism sets a providerID itself, document
providerIDSource: Adopt; otherwise consumeKPSSH_PROVIDER_ID. - Test each phase by hand first — the env preamble makes that trivial:
ssh host 'sudo env KPSSH_CLUSTER_ENDPOINT=… KPSSH_BOOTSTRAP_TOKEN=… bash -s' < join.sh
- Bump
spec.versionwheneverinstallchanges behavior — that is the only cache invalidation. - Mind the leave/uninstall split:
leaveruns on every scale-down and must keep the warm-pool invariant; destructive cleanup goes touninstall. - Keep
leaveself-contained — no.Vars, no.Secrets, no template data beyond the host's addresses (contract). A profile that breaks this is rejected: every SSHNodeClass using it reportsReady=False/ProfileInvalidand karpenter stops launching against it.