Install the operator

This guide installs display-operator on a liken cluster. At the end, every monitor output on the cluster is a device a workload can claim.

You need:

1. Check that the card publishes

The machine with the monitors must publish its graphics card as a device. Look for a displayNode attribute in that node’s liken.sh ResourceSlice:

kubectl get resourceslice <node>-liken.sh -o yaml

If no device has displayNode, the operator’s own claim will park and its pod will stay Pending. Claiming hardware explains why: liken publishes the whole card, and this operator claims it and publishes each of its outputs.

2. The device classes

A DeviceClass is cluster-scoped policy. You name and curate the classes, using the same convention as a StorageClass. The classes split by owner:

Generic or specific

A class is the cluster’s vocabulary for a kind of device, and you choose its grain. display-output above is generic: it matches every monitor output, keeps the class list short, and leaves the choice of screen to each claim’s selector, written in Common Expression Language (CEL) . A specific class holds the selector itself. A claim then names the class and writes no CEL, and you make the choice once, in cluster policy you control:

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: lobby-screen
spec:
  selectors:
    - cel:
        expression: |
          device.driver == "display.liken.sh" &&
          has(device.attributes["display.liken.sh"].appId) &&
          device.attributes["display.liken.sh"].connector == "HDMI-A-1"

Start with a generic class. When several workloads repeat the same selector, or when cluster policy should choose the screen instead of each workload manifest, create a specific class.

The example selects by connector, an attribute every output always publishes. The appId guard is there because the panel’s control device publishes connector too. A specific class that selects by a monitor attribute, such as model, must guard the read with has(), the way Put a window on a screen shows. Those attributes are absent on a dark connector, and a selector that reads a missing attribute fails the whole allocation.

3. Apply the manifests

This site serves the repository’s deploy/ directory as raw YAML, so the install needs no clone. Seven files are the rest of the install, and api.yaml is the one that runs once per cluster rather than once per node:

kubectl apply -n liken-system \
  -f https://liken.sh/display/deploy/displays.yaml \
  -f https://liken.sh/display/deploy/layouts.yaml \
  -f https://liken.sh/display/deploy/deviceclasses.yaml \
  -f https://liken.sh/display/deploy/rbac.yaml \
  -f https://liken.sh/display/deploy/operator.yaml \
  -f https://liken.sh/display/deploy/api.yaml

api.yaml binds one Role in kube-system, which -n would refuse, so that binding is in a file of its own. Apply it with no -n:

kubectl apply -f https://liken.sh/display/deploy/api-authentication.yaml

api.yaml holds the display-api Deployment, its Service, and its RBAC. It answers the routes the API reference describes, and an owner who wants no capture API leaves it out.

displays.yaml is the Display CustomResourceDefinition. The operator creates a Display for every monitor it probes, and it cannot do that on a cluster where the kind is missing.

layouts.yaml is the Layout CustomResourceDefinition. A Layout divides a screen into regions, as the layout guide shows. The operator reads it by the name a Display states and watches the kind for changes.

The -n flag places the ServiceAccount and the DaemonSet in liken-system, the namespace every liken cluster has. The ClusterRoleBinding’s subject names that namespace, so the binding only works there. DeviceClass and the CustomResourceDefinitions are cluster-scoped, so the flag leaves them alone.

For GitOps, point a Kustomization at your specific classes and the same URLs. kustomize takes a raw YAML URL as a resource:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
transformers:
  - |-
    apiVersion: builtin
    kind: NamespaceTransformer
    metadata:
      name: liken-system
      namespace: liken-system
    unsetOnly: true
resources:
  - classes.yaml
  - https://liken.sh/display/deploy/displays.yaml
  - https://liken.sh/display/deploy/layouts.yaml
  - https://liken.sh/display/deploy/deviceclasses.yaml
  - https://liken.sh/display/deploy/rbac.yaml
  - https://liken.sh/display/deploy/operator.yaml
  - https://liken.sh/display/deploy/api.yaml
  - https://liken.sh/display/deploy/api-authentication.yaml

The transformer sets liken-system only on the objects that state no namespace, so the binding in api-authentication.yaml stays in kube-system. A top-level namespace: field would move it, and the API couldn’t read the client certificate authority.

A clone works too: kubectl apply -k display-operator/deploy/ from the repository applies the same base through deploy/kustomization.yaml .

4. Watch the operator find the screens

The operator runs as a DaemonSet, so a pod lands on every node and no manifest names the machine with the monitors. Each pod claims the card on its own node. On a node with no graphics card, the claim finds no device and the pod parks Pending, which costs nothing. A node labeled display.liken.sh/display: none gets no pod, as Keep the pods off nodes with no graphics card describes.

kubectl -n liken-system get pods -o wide

On the machine with the card, the pod’s log names each monitor it found:

kubectl -n liken-system logs ds/display-operator
display.liken.sh: operating the monitors on kitchen
display.liken.sh: HDMI-A-1 has gsm-7716-lg-hdr-wqhd

5. See the devices

The operator publishes one device for each connector on the card, into a ResourceSlice named <node>-display.liken.sh:

kubectl get resourceslice <node>-display.liken.sh -o yaml

A connector with a monitor has the monitor’s attributes. An empty connector publishes too, with a disconnected taint, so a claim on it parks until a monitor arrives. Devices describes every attribute.

The operator also creates one Display per monitor, the cluster-scoped resource that reports the panel’s controls and takes declarations:

kubectl get displays

Displays describes the resource.

Now put a window on a screen .

Keep the pods off nodes with no graphics card

The DaemonSet makes a pod on every node. On a node with no graphics card, the claim matches no device, and the pod stays Pending. To make no pod on such a node, label the node display.liken.sh/display: none.

The DaemonSet in the base carries this node affinity, so no patch is needed:

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: display.liken.sh/display
              operator: NotIn
              values: ["none"]

NotIn matches a node whose label has a different value, and also a node that has no such label. The Kubernetes page on set-based requirements gives this rule. So with no label the DaemonSet makes a pod on every node, and a node labeled none gets no pod. When a label changes, the DaemonSet controller deletes the pod from a node that no longer matches, and adds one to a node that matches again.

Declare the label in the node’s Machine, so the label is part of the record of the machine:

spec:
  nodeLabels:
    display.liken.sh/display: none

A Machine accepts a key in a liken.sh subdomain from liken 2026.09.28-002 on, and an older release refuses it, so on an older release use the kubectl label way below.

The liken machine operator applies the label to the running node. A machine that is demoted or installed again registers a new Node, and the Node has the label from registration. To run the pod on that node again, remove the key from spec.nodeLabels, and liken removes the label from the node. liken also removes a label that you set with kubectl before the Machine declared it, when the key leaves the spec.

You can also label the node with kubectl:

kubectl label node node-1 display.liken.sh/display=none

The label stays on the node across reboots. liken leaves it in place, because liken removes only the labels that a Machine declared. The label goes with the Node object: a machine that is demoted or installed again registers a new Node, and you label it again. To run the pod on that node again, remove the label:

kubectl label node node-1 display.liken.sh/display-

A patch of your own that sets a node affinity on this DaemonSet replaces the list of terms in the base, and the none term with it. Copy the none requirement into each term of your patch.

Running a development build

A push to main that changes the operator publishes a development build of it. Its version is the most recent release tag plus a suffix: 2026.09.03-007-dev-003-abcdef01 is three commits past release 2026.09.03-007, at commit abcdef01. Every image of the operator has the same version, and :latest still names the most recent release.

A development build has no git tag, so the manifests pin to the commit’s full sha, and the image pins to the version:

resources:
  - https://github.com/liken-sh/liken//display-operator/deploy?ref=<full 40-character sha>
images:
  - name: ghcr.io/liken-sh/display-operator
    newTag: 2026.09.03-007-dev-003-abcdef01
  - name: ghcr.io/liken-sh/display-capture
    newTag: 2026.09.03-007-dev-003-abcdef01
  - name: ghcr.io/liken-sh/display-api
    newTag: 2026.09.03-007-dev-003-abcdef01

The operator DaemonSet runs display-operator and display-capture, and the display-api Deployment runs display-api. Pin all three images to the same version. A pin on one image leaves the others on :latest.

A git fetch by sha needs all forty characters; the eight in the version are not enough. The summary of the CI run for that commit gives the version.

The same build publishes the manifests as the OCI artifact oci://ghcr.io/liken-sh/display-operator-deploy:<version>, with each image of the operator set to that version. A Flux OCIRepository can pull that artifact by the version, with no sha.

Remove the operator

Delete the manifests. Then delete the slice on each node that published one:

kubectl delete -n liken-system \
  -f https://liken.sh/display/deploy/api.yaml \
  -f https://liken.sh/display/deploy/rbac.yaml \
  -f https://liken.sh/display/deploy/operator.yaml
kubectl delete -f https://liken.sh/display/deploy/api-authentication.yaml
kubectl delete resourceslice <node>-display.liken.sh

The API’s own Secrets and ConfigMap outlive the Deployment. Delete display-api-tls, display-capture-server, and display-api-ca in liken-system by hand.

The slice step is yours because the operator never deletes its slice. A device that leaves the inventory while a claim still names it strands the kubelet’s prepare call. So the operator taints devices instead of removing them, and the slice outlives every pod. The device classes are yours too. When nothing else claims through them, delete them.

Deleting the Display CRD deletes every Display with it, including the brightness a standing override captured. A panel an override darkened then has nothing left to restore it. Lift every override, and confirm every panel shows what you expect, before you delete displays.yaml.

Deleting layouts.yaml deletes every Layout with it, and the arrangements they hold. Delete it after the last screen that names a Layout no longer needs one.