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:
- A
likencluster. The operator claims the graphics card through Dynamic Resource Allocation (DRA) , from the devicesliken’s own driver publishes. Devices describes those. - A machine in that cluster with a graphics card, with a monitor on a connector.
kubectlwith cluster-admin access, because the install touches cluster scope: theDeviceClassesyou create in step 2, aClusterRole, and theDisplayCustomResourceDefinition.
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:
-
display-gpu,display-render, anddisplay-i2care wiring, and the base ships them, served atdeviceclasses.yaml. The operator’s own pod claims the graphics card’s card node, its render node, and its monitor-control wires through them, from the deviceslikenpublishes. TheResourceClaimTemplateinoperator.yamlnames them literally, so the operator cannot start without them. Do not delete them. The classes select on thedisplayNodeandrenderNodeattributes and on the i2c companion’ssubsystem. They do not select on a vendor and a product id, so they stay correct across a fleet of different machines. -
The class your workloads claim through is yours to create, because it is your cluster’s vocabulary, and the base ships no policy.
display-outputis the one to start with:apiVersion: resource.k8s.io/v1 kind: DeviceClass metadata: name: display-output spec: selectors: - cel: expression: | device.driver == "display.liken.sh" && has(device.attributes["display.liken.sh"].appId)The
appIdguard keeps the class on outputs. The driver also publishes each panel’s control device , which has noappId, and a class that matched the whole driver would allocate either one.
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.