Skip to content

Device Enrollment Guide

This guide walks through bringing a new controller device (an x86_64 industrial PC or equivalent) into the control system. Enrollment has two distinct halves, and they are owned by different layers (ADR 0004):

  1. Cluster join — making the device a Kubernetes node. This is the deployment layer's job, done with whatever mechanism your cluster distribution provides (Talos machine config, k3s token, cloud nodegroup). The product takes no part in it.
  2. DCS registration — binding that node to a Controller resource in a site, labeling it for unit scheduling, and tracking its health. This is the product's job and is what the rest of this guide covers.

Next step after enrollment

Once the device is joined and reports Joined, the next step is to attach I/O. See I/O for the end-to-end workflow from IOModule authoring to a phase running against real equipment.

sequenceDiagram
    participant Deploy as Deployment layer (talosctl, …)
    participant K8s as Kubernetes
    participant Op as Physical operator
    participant UI as Gateway UI / dcs CLI

    Deploy->>K8s: 1. Join node (machine config / join token)
    Note over Deploy,K8s: node carries dcs.io/site=<site> label
    K8s-->>Op: 2. Node appears
    Op->>Op: 3. Auto-create Controller CR (node discovery)
    UI-->>Op: (or: Controller CR pre-created by name)
    Op->>K8s: 4. Reconcile spec.nodeLabels onto node
    Op->>UI: 5. Controller: Pending -> Joining -> Joined

Using the reference plant? The plant-01 plant uses simulation controllers (spec.simulation: true) that skip enrollment entirely — the physical operator creates a virtual Kubernetes node and maintains its heartbeat lease, no hardware involved. This guide is only relevant if you are enrolling a real edge device against your own control plane. See Deploy Your Own for the self-host track.

Prerequisites

Component Notes
Cluster-admin access via your distro's tooling e.g. talosctl + machine config for Talos, a join token for k3s
DCS operators + gateway installed Via the Helm chart
Enterprise + Site created So that the site-<name> namespace exists
Device meets node requirements x86_64, able to pull the unit-runtime image, host ports outside 32768–60999 free (see Architecture § Port Allocation Policy)

Step 1: Join the node (deployment layer)

Join the device to the cluster with your distribution's own mechanism. The product does not broker join credentials. Node join is a change-managed infrastructure activity, performed with the same tooling that built the cluster.

For Talos (the reference deployments), generate a worker machine config and apply it, including the DCS labels at join time:

# patch.yaml — Talos worker machine config patch
machine:
  nodeLabels:
    dcs.io/site: newark-plant        # triggers auto-discovery (Step 2, Option A)
    dcs.io/device: edge-mixing-1      # optional: unit scheduling label
talosctl machineconfig patch worker.yaml --patch @patch.yaml -o node.yaml
talosctl apply-config --insecure -n <device-ip> --file node.yaml

The golden-path join runbook for the reference deployment lives in the deploy-reference repository, because the runbook is a deployment concern. For any other distribution, any join mechanism is fine. The product only sees the resulting Node object.

Step 2: Register the Controller

A Controller resource in the site namespace represents the device. There are two equivalent ways to create it. Pick one:

Option A: label-driven auto-discovery (recommended). If the node joined carrying the dcs.io/site=<site> label (as in Step 1), the physical operator discovers it and creates the Controller automatically:

  • The Controller is named after the node and created in site-<site>.
  • Any dcs.io/* labels already on the node are captured into spec.nodeLabels.
  • The Controller is annotated dcs.io/discovery: auto. Removing the site label from the node cleans it up again.
  • Control-plane nodes are never auto-discovered.

Option B: pre-create the Controller by name. Create the Controller before (or after) the join. The reconciler binds it to the cluster node of the same name as soon as that node appears. Use this when the device's identity should exist in the DCS (and in GitOps) ahead of the physical install:

  1. Open the gateway UI in your browser.
  2. Select a Site from the sidebar.
  3. Click the Controllers tab.
  4. Click + Add Controller.
  5. Enter:
    • Name — must equal the Kubernetes node name the device will join with (e.g., edge-mixing-1).
    • Node labels — optional, comma-separated key=value pairs (e.g., dcs.io/device=edge-mixing-1).
  6. Click Create.
dcs create controller --name edge-mixing-1 \
  --labels dcs.io/device=edge-mixing-1 \
  -s newark-plant
POST /api/v1/sites/newark-plant/controllers
Content-Type: application/json

{
  "name": "edge-mixing-1",
  "nodeLabels": {
    "dcs.io/device": "edge-mixing-1"
  }
}

Step 3: Node labels for unit scheduling

Controller.spec.nodeLabels is the product-side source of truth for the device's DCS labels. The physical operator converges these labels onto the bound node. You do not need to run kubectl label, and the labels do not have to be present at join time:

  • Only labels under the dcs.io/ prefix are reconciled. The operator never touches other node labels.
  • Labels the operator applied earlier but that were removed from spec.nodeLabels are removed from the node again (tracked via the physical.dcs.io/applied-node-labels annotation).
  • Editing node labels in the UI or via PATCH /api/v1/sites/{site}/controllers/{name} takes effect on the live node.

The key convention is dcs.io/device=<unit-name>: a Unit whose spec.nodeSelector names that label gets its runtime pod scheduled onto this device. See Architecture for the scheduling model.

Step 4: Watch the Controller join

Site Units: controller and runtime status after enrollment

In the gateway UI, the Controller transitions through phases automatically:

Phase What's happening
Pending Controller created; transitions to Joining immediately
Joining Waiting for a cluster node with the Controller's name to appear
Joined Node found; labels reconciled; address discovered

The Address column populates with the device's IP (discovered from the node's InternalIP) once the Controller reaches Joined.

Phase is an adoption lifecycle, so it answers whether the device has been taken into the control system. It is not a health readout, and it stays at Joined if the device later stops answering, because an unreachable device is still an adopted one. The Health column beside it is the readout for right now: Healthy when the bound node is Ready, Degraded when the node is Ready but the I/O probe is not running, and Offline when the node has stopped reporting Ready. Hovering the health dot gives the reason behind it.

You can also watch from the CLI:

dcs get controllers -s <sitename>
# NAME            ADDRESS      PHASE    HEALTH    NODE
# edge-mixing-1   10.0.0.100   Joined   healthy   edge-mixing-1

Removing a Controller

Whichever path you use, the reconciler:

  1. Cordons the node (marks unschedulable)
  2. Drains the node, evicting every pod that is neither owned by a DaemonSet nor a kubelet mirror pod, and waits for those pods to leave
  3. Deletes the node from the control system, once the drain has finished

The drain decides whether step 3 happens at all. An eviction that a PodDisruptionBudget refuses, or a pod still inside its termination grace period, leaves workloads on the node, and the removal refuses to delete a node out from under them. The Controller stays in Removing and carries a NodeDrained condition naming the pods that are holding it back, and the reconciler keeps retrying until they move. After five minutes the condition reason becomes DrainTimedOut and the retry slows down, but the removal is still refused until the node is empty.

/system → pick the site → Controllers tab → click Remove next to the controller row → confirm the dialog.

dcs delete controller edge-mixing-1 -s newark-plant
DELETE /api/v1/sites/newark-plant/controllers/edge-mixing-1

Note: removing the Controller does not unconfigure the machine itself. Decommission the node with your deployment layer's tooling (e.g. talosctl reset on Talos), mirroring how it was joined.


Troubleshooting

Use the Diagnostics page to check service health and per-site runtime status when debugging enrollment issues:

Diagnostics: service health and per-site runtime status

Symptom Fix
Node joined but no Controller appeared Auto-discovery needs the dcs.io/site=<site> node label and an existing Site CR of that name. Check both, or pre-create the Controller (Option B).
Controller stuck in Joining No cluster node named exactly like the Controller exists yet. Check the join on the deployment side (kubectl get nodes); verify the node name matches the Controller name.
Controller goes to Failed Node disappeared or never appeared. Check the Diagnostics page and device connectivity.
Controller stuck in Removing The node has not drained. Read the NodeDrained condition (kubectl describe controller <name> -n site-<sitename>): it names the pods still on the node and the ones a PodDisruptionBudget refused to evict. Move or scale those workloads, or relax the budget, and the removal finishes on its own.
Unit runtime not scheduling onto the device Verify dcs.io/device=<unit-name> is in the Controller's spec.nodeLabels and shows on the node (kubectl get node <name> --show-labels), and that the Unit's nodeSelector matches.
Labels edited in the UI but not on the node Only dcs.io/-prefixed labels are reconciled onto the node. Anything else in spec.nodeLabels is intentionally left alone.

History: token-based enrollment (removed)

Versions before June 2026 shipped an in-product enrollment flow: the gateway minted single-use tokens, a dcs-enroll agent on the device redeemed them via POST /api/v1/enroll, and the device installed a k3s agent from the returned join credentials. That flow only worked on k3s clusters and was removed in favor of the deployment-layer contract above. See ADR 0004 for the rationale.