
# Install the operator

This guide installs `audio-operator` on a
[`liken`](https://liken.sh/docs/) cluster. At the end, every
physical audio output on the cluster is a device a workload can
claim.

You need:

* A `liken` cluster. The operator claims the sound card through
  [Dynamic Resource Allocation (DRA)](https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/),
  from the devices `liken`'s own driver publishes.
  [Devices](https://liken.sh/docs/reference/devices/) describes
  those.
* A machine in that cluster with a sound card: a monitor with
  speakers on HDMI or DisplayPort, or something wired to the analog
  jack.
* For Bluetooth speakers, the
  [`bluetooth-operator`](https://liken.sh/bluetooth/) on the same
  machine. Its media bus puts the sound server on `bluetoothd`'s
  bus. The `bluetooth-operator` is optional, and a machine with a
  card and no radio installs nothing extra.
* `kubectl` with cluster-admin access. You create cluster-scoped
  [`DeviceClasses`](https://kubernetes.io/docs/reference/kubernetes-api/resource/device-class-v1/)
  yourself, and the base creates a `ClusterRole` and two
  `CustomResourceDefinitions`.

## 1. Check that the card publishes

The machine with the speakers must publish its audio controller as a
device. Look for a device stamped
`sound.liken.sh/supportsSound: {bool: true}` in that node's
`liken.sh` `ResourceSlice`, which is the fact the operator's
`sound-card` class selects:

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

If no device has the stamp, the operator's own claim will park and
its pod will stay `Pending`.
[Claiming hardware](https://liken.sh/docs/concepts/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, yours to name and curate,
the same convention a `StorageClass` follows. The classes split by
owner:

* `sound-card` is wiring, and the base ships it, served at
  [`deviceclasses.yaml`](/deploy/deviceclasses.yaml). The
  operator's own pod claims every sound device on its node through
  it, and the claim template in the served
  [`operator.yaml`](/deploy/operator.yaml) names it literally, so
  the operator cannot start without it. Do not delete it.
* The classes your workloads claim through are yours to create,
  because they are your cluster's vocabulary, and the base ships no
  policy. `audio-sink` and `audio-source` are the ones to start
  with. Each covers one direction of what this driver publishes: a
  playback endpoint has the `sink` attribute and a capture
  endpoint has `source`:

        apiVersion: resource.k8s.io/v1
        kind: DeviceClass
        metadata:
          name: audio-sink
        spec:
          selectors:
            - cel:
                expression: has(device.attributes["audio.liken.sh"].sink)
        ---
        apiVersion: resource.k8s.io/v1
        kind: DeviceClass
        metadata:
          name: audio-source
        spec:
          selectors:
            - cel:
                expression: has(device.attributes["audio.liken.sh"].source)

### Generic or specific

A class is the cluster's vocabulary for a kind of device, and you
choose its grain. `audio-sink` above is generic: it matches every
playback endpoint, it keeps the class list short, and it leaves the
choice of output to each claim's CEL selector. 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: analog-jack
    spec:
      selectors:
        - cel:
            expression: |
              has(device.attributes["audio.liken.sh"].sink) &&
              has(device.attributes["audio.liken.sh"].connectionType) &&
              device.attributes["audio.liken.sh"].connectionType == "analog"

A class that names one monitor's speakers through
`monitor.liken.sh/id` works the same way, with the same `has()`
guard on the attribute.

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.

## 3. Apply the manifests

This site serves the repository's
[`deploy/`](/deploy/kustomization.yaml) directory as raw YAML, so
the install needs no clone. Six files are the rest of the install:

    kubectl apply -n liken-system \
      -f https://liken.sh/audio/deploy/crds.yaml \
      -f https://liken.sh/audio/deploy/deviceclasses.yaml \
      -f https://liken.sh/audio/deploy/rbac.yaml \
      -f https://liken.sh/audio/deploy/operator.yaml \
      -f https://liken.sh/audio/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/audio/deploy/api-authentication.yaml

`crds.yaml` holds the `Sink` and `Source`
`CustomResourceDefinitions`, the resources the operator creates for
every endpoint it publishes. The
[`Sink`](/docs/reference/sinks/) and
[`Source`](/docs/reference/sources/) references describe them.

`api.yaml` holds `audio-api`, a `Deployment` and a `Service` that run
once for the whole cluster, with the RBAC they need. The
[listen guide](/docs/guides/listen/) depends on it. A cluster that
needs no capture API can leave this file out.

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` is cluster-scoped, so the flag
leaves it alone.

For GitOps, put your specific classes in a file of your own and
point a `Kustomization` at it and at the served 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/audio/deploy/crds.yaml
      - https://liken.sh/audio/deploy/deviceclasses.yaml
      - https://liken.sh/audio/deploy/rbac.yaml
      - https://liken.sh/audio/deploy/operator.yaml
      - https://liken.sh/audio/deploy/api.yaml
      - https://liken.sh/audio/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 audio-operator/deploy/` from the
repository applies the same files through
[`deploy/kustomization.yaml`](/deploy/kustomization.yaml).

## 4. Watch the operator find the outputs

The operator runs as a `DaemonSet`, so a pod lands on every node and
no manifest names the machine with the speakers. Each pod claims
every audio controller on its own node. On a node with no sound
card, the claim finds no device and the pod parks `Pending`, which
costs nothing. A node labeled `audio.liken.sh/sound-card: none` gets
no pod, as
[Keep the pods off nodes with no sound card](#keep-the-pods-off-nodes-with-no-sound-card)
describes.

    kubectl -n liken-system get pods -o wide

The pod is five containers from one image. A `declare` init
container writes PipeWire's sink declarations, PipeWire and
WirePlumber run as sidecars, the `operator` container publishes what
they hold, and the `capture` container serves the taps that
`audio-api` forwards. On the machine with the card, the operator's log reports the
slice it wrote:

    kubectl -n liken-system logs ds/audio-operator
    audio.liken.sh: operating the audio controller on kitchen
    slice: created generation 1, 3 devices, 0 tainted

The image is a file closure on `scratch`: no shell, no package
manager. Use `pw-dump` to inspect the running sound server:

    kubectl -n liken-system exec ds/audio-operator -c operator -- pw-dump

Three more tools ship in the same image, for the times the graph
reads correctly and the sound is still wrong. Each runs as its own
`kubectl exec`, with no shell between. `pw-top -b -n 1` prints one
reading of every node, and its `ERR` column counts the dropouts.
`pw-cli` lists any object in the graph and writes a parameter on
one with `set-param`, with no restart of the daemon. `pw-metadata`
reads and writes the graph's settings, for example
`clock.force-quantum`.

    kubectl -n liken-system exec ds/audio-operator -c operator -- pw-top -b -n 1
    kubectl -n liken-system exec ds/audio-operator -c operator -- pw-cli info 0
    kubectl -n liken-system exec ds/audio-operator -c operator -- pw-metadata -n settings

## 5. See the devices

The operator publishes one device for each PCM device on the
claimed card, sinks and sources alike, into a `ResourceSlice` named
`<node>-audio.liken.sh`:

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

The same endpoints are resources of their own, one `Sink` per
playback endpoint and one `Source` per capture endpoint, named
like the devices:

    kubectl get sinks
    NAME                                            NODE      CONNECTION   VOLUME   MUTE    CLAIM   CONNECTED   READY   AGE
    kitchen-pci-0000-00-1f-3-hdmi-0                 kitchen   hdmi         100      false           True        True    2m
    kitchen-usb-0573-1573-a34004801402-usb-audio    kitchen   usb          100      false           True        True    2m

    kubectl get sources
    NAME                                                    NODE      CONNECTION   VOLUME   MUTE    CLAIM   CONNECTED   READY   AGE
    kitchen-usb-0573-1573-a34004801402-usb-audio-capture    kitchen   usb          100      false           True        True    2m

An output whose monitor answers publishes the monitor's attributes.
An HDMI output with no monitor publishes too, with taints, so a
claim on it parks until a monitor arrives.
[Devices](/docs/reference/devices/) describes every attribute.

When the pod's claim also allocated a Bluetooth media bus, the same
slice holds one device for each paired Bluetooth speaker. A speaker
that is switched off publishes with taints, the same way an HDMI
output with no monitor does.

Now [play sound to an output](/docs/guides/claim/), or
[set an endpoint's volume and controls](/docs/guides/rest/).

## Keep the pods off nodes with no sound card

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

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

```yaml
affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: audio.liken.sh/sound-card
              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](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#set-based-requirement)
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`](https://kubernetes.io/docs/concepts/workloads/controllers/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:
        audio.liken.sh/sound-card: 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 audio.liken.sh/sound-card=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 audio.liken.sh/sound-card-

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.

## Pin a release

The site serves the manifests of the current `main`, and the images
in the manifests name `:latest`. To pin a release instead, reference
the operator's `kustomize` base at the operator's version, which is
the release tag that last published it. The
[GitHub release](https://github.com/liken-sh/liken/releases) for each
tag lists every component and its version. One tag versions the
manifests and the image together, so pin both to the same version:

    apiVersion: kustomize.config.k8s.io/v1beta1
    kind: Kustomization
    resources:
      - https://github.com/liken-sh/liken//audio-operator/deploy?ref=<version>
    images:
      - name: ghcr.io/liken-sh/audio-operator
        newTag: <version>

## 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:

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

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/audio-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/audio/deploy/rbac.yaml \
      -f https://liken.sh/audio/deploy/operator.yaml \
      -f https://liken.sh/audio/deploy/api.yaml
    kubectl delete -f https://liken.sh/audio/deploy/api-authentication.yaml
    kubectl delete resourceslice <node>-audio.liken.sh

The API's own `Secret`s and `ConfigMap` outlive the `Deployment`.
Delete `audio-api-tls`, `audio-capture-server`, and `audio-api-ca` in
`liken-system` by hand.

This leaves the `DeviceClasses` in place: `sound-card` from the
base, and the consumer classes you created. Delete them when no
other claim names them. Deleting `crds.yaml` deletes every `Sink`
and `Source` with it, and the declarations they hold:

    kubectl delete deviceclass sound-card audio-sink audio-source
    kubectl delete -f https://liken.sh/audio/deploy/crds.yaml

The second 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.

