Install the operator

This guide installs bluetooth-operator on a liken cluster and verifies that it holds the radio. The operator is an ordinary workload: everything it needs is in one kustomize base, and nothing here touches a machine over SSH.

What you need

The device classes

A DeviceClass is cluster-scoped policy, the same convention a StorageClass follows: the cluster owner names and curates the classes workloads may ask for. The classes split by owner. If the DRA objects are new to you, read How the pieces fit first.

The guard on the input attribute also keeps the adapter’s media bus out of this class. The bus is the audio operator’s to claim, through a class of its own that names the shared sound.liken.sh/supportsSound attribute.

Generic or specific

A class is the cluster’s vocabulary for a kind of device, and you choose its grain. A generic class such as bluetooth-input matches every paired input device: the class list stays short, and each claim picks its controller with a CEL selector. A specific class holds the selector itself. This one matches exactly one controller, so a claim 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: player-one-dualsense
spec:
  selectors:
    - cel:
        expression: |
          device.driver == "bluetooth.liken.sh" &&
          device.attributes["bluetooth.liken.sh"].address == "A0:AB:51:33:B7:12"

Start generic. When several workloads repeat the same selector, or when you want the choice in cluster policy rather than in each workload’s manifest, create a specific class.

Apply the manifests

This site serves the repository’s manifests as raw YAML under /deploy/, so you can install from here without a clone. Apply the three files into liken-system, the namespace a liken cluster already has:

kubectl apply -n liken-system \
  -f https://bluetooth.liken.sh/deploy/crds.yaml \
  -f https://bluetooth.liken.sh/deploy/rbac.yaml \
  -f https://bluetooth.liken.sh/deploy/operator.yaml

Or point your own GitOps at the same files with a Kustomization:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: liken-system
resources:
  - https://bluetooth.liken.sh/deploy/crds.yaml
  - https://bluetooth.liken.sh/deploy/rbac.yaml
  - https://bluetooth.liken.sh/deploy/operator.yaml

The site serves the manifests of the current main, and the images in operator.yaml name :latest. To pin a release instead, reference the repository’s kustomize base at a release tag. One tag versions the manifests and the three images together, so pin all four to the same version:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://github.com/liken-sh/bluetooth-operator//deploy?ref=2026.08.17-022
images:
  - name: ghcr.io/liken-sh/bluetooth-operator
    newTag: 2026.08.17-022
  - name: ghcr.io/liken-sh/bluetoothd
    newTag: 2026.08.17-022
  - name: ghcr.io/liken-sh/bluetooth-bondfetch
    newTag: 2026.08.17-022

Whichever path you take, the manifests contain:

How the pod finds the radio

The DaemonSet puts a pod on every node, and each pod claims one bluetooth-adapter device. On a node with an adapter the claim matches and the pod runs. On a node with no adapter the claim matches nothing, so the pod parks Pending and costs nothing. Nobody writes down which machine has the radio, and a dongle moved to another machine is served there on the next pod start.

The claim also makes the pod the only Bluetooth stack on that radio, because liken publishes the adapter as a device that allocates once. The kernel arbitrates nothing between two stacks on one adapter.

Verify

kubectl get pods -n liken-system -l app=bluetooth-operator

One Running pod on each machine with an adapter, and a Pending pod on each machine without one, is the healthy shape. Then read the radio the operator holds:

$ kubectl get adapters
NAME                ALIAS   ADDRESS             NODE      POWERED   AGE
04-4a-69-66-92-27           04:4A:69:66:92:27   liken-1   true      1m

The operator creates an Adapter object for the radio its pod claimed, named for the radio’s address. The ResourceSlice of paired controllers appears when the first controller is paired: Pair a controller and give it to a pod is the next step.

Look inside the stack

The bluetoothd image holds four tools for a person. Each runs as a direct kubectl exec, with no shell between, and every one of them needs the -i flag: BlueZ’s shells attach to their standard input, and with stdin closed the attach fails and the command never runs, with nothing printed.

btmgmt info prints the adapter’s management settings, and its current settings line is where Connectable, Discoverable, and Bondable read. btmon traces the HCI link live, the layer under D-Bus and under bluetoothd: it shows a disconnect reason or a retransmission that no higher layer reports. dbus-send calls any method on org.bluez. bluetoothctl list names what the daemon holds.

kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- btmgmt info
kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- btmon
kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- bluetoothctl list

One limit: the image has no shell, and BlueZ’s argument parser runs one, so bluetoothctl and btmgmt refuse every command that takes an argument (“Unable to parse mandatory command arguments”). Only their no-argument commands work: bluetoothctl list, and btmgmt info, extinfo, con, keys, and ltks. dbus-send has no such limit, so it is the way to reach anything else. This is how you connect one device by hand:

kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- \
  dbus-send --system --print-reply --dest=org.bluez \
  /org/bluez/hci0/dev_A0_AB_51_33_B7_12 org.bluez.Device1.Connect

The privilege it takes

The pod is three containers, and the privilege is confined to one of them. NET_RAW is the one capability bluetoothd itself does not use: it is there for btmon, whose bind of the kernel’s HCI monitor channel tests CAP_NET_RAW. The bluetoothd container takes hostNetwork and five capabilities (NET_ADMIN, NET_RAW, NET_BIND_SERVICE, SETUID, SETGID), because it is the Bluetooth stack. The operator and bondfetch containers drop every capability. The comments in deploy/operator.yaml state the kernel or daemon check behind each grant.

The pod mounts four host paths: the two kubelet plugin directories every DRA driver takes, /var/run/cdi, and /var/run/bluetooth.liken.sh/dbus, which holds the D-Bus socket a claim on the media bus delivers. The bus directory is a host path so that a prepared claim names the same socket across a restart of this pod.

Uninstall

Delete the workload. The published ResourceSlice stays, because the operator does not retract it on shutdown: its pod restarts for ordinary reasons while consumers hold prepared claims. The Node owns the slice, so a node that leaves the cluster takes it along. To remove it now:

kubectl delete resourceslice <node>-bluetooth.liken.sh