Install the operator
This guide installs library-operator on a
liken
cluster. At the end, the operator
runs, a namespace has its catalog, and a Library can be declared.
You need:
- A
likencluster. - The
media-operator, installed first, inliken-system. It owns the players, the plays, and the remotes, and its bus atbus.liken-system.svc:1883is where every catalog reports. ALibraryin a cluster with no bus reportsOfflineand never reachesReady. - A volume that holds the media, as a
PersistentVolumeClaimin the namespace where theLibrarywill be. The operator never creates this claim. See The media claim below. - A
StorageClassfor the catalogs. The operator provisions those claims itself: one for the catalog pods, one perLibrary, and two per screen. See The catalog claims . kubectlwith cluster-admin access. The base creates aClusterRoleand threeCustomResourceDefinitions.
This operator publishes no devices and needs no DeviceClass. The
screen it draws uses the Player’s existing display claim, which
media-operator holds.
1. Create the storage
The media claim
A Library names an existing claim in spec.storage.claim. Any
volume the cluster can mount works: an NFS export, a Longhorn volume,
a local disk on a single-node cluster.
Two kinds of pod mount the claim, and they can land on different
nodes. A screen mounts it read-only. The Library’s Job mounts it
read-write, because its phases write the .nfo and art files beside
the media, and its scan container mounts it read-only. So the claim
has to allow more than one node at once, which on most clusters means
ReadWriteMany:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: movies-pvc
namespace: media
spec:
accessModes: [ReadWriteMany]
resources:
requests:
storage: 500Gi
storageClassName: nfs
A ReadWriteOnce claim works only when every pod that mounts it is
on the same node, which nothing in the schema enforces.
A franchises library needs a second claim for the art its scan downloads. Franchises covers it.
The catalog claims
The catalog is SQLite, and one agent writes each copy. So the operator
provisions a catalog claim as ReadWriteOnce, and as ReadWriteMany
on a per-node class, where every node holds a copy of its own. A
Library’s claim on a per-node class is ReadWriteOncePod, so
Kubernetes admits one pod of it in the cluster at a time. The
namespace’s Catalog names the class of each kind of claim:
spec.storage.storageClassNamefor the catalog pod’s claim, the catalog of record every other agent syncs from.spec.progress.storageClassNamefor the progress store’s claim, the record of who watched what. It defaults to the catalog’s class, andspec.progress.sizegives the store a size of its own.spec.libraries.storageClassNamefor the catalog claim of everyLibrary,<library>-catalog. It defaults to the catalog’s class.spec.screens.storageClassNamefor both claims of every screen.
A class left empty binds to the cluster’s default class.
Keep the catalog of record and the progress store on classes that
survive a lost node, such as block storage from a SAN. A SQLite file on
NFS can corrupt when its
node is lost, so do not use an NFS class. A Library’s claim is a
working copy that a Job rebuilds from the catalog of record, and a
screen pod is pinned to the machine that holds its display. A
node-local class such as local-path fits both.
2. Apply the manifests
The install is the kustomize base in the operator’s
deploy/
directory. Take it into a
kustomization of your own and pin <tag> to the operator’s version,
so the install is the same every time it is applied:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: liken-system
resources:
- https://github.com/liken-sh/liken//library-operator/deploy?ref=<tag>
images:
- name: ghcr.io/liken-sh/library-operator
newTag: <tag>
The operator’s version is the release tag that last published it. The GitHub release for each tag lists every component and its version.
The base creates the three CustomResourceDefinitions, a
ServiceAccount, a ClusterRole with its binding, a Role with its
binding, one Deployment, and one Service. The Deployment runs one
unprivileged replica with every capability dropped. The Service is
the address every Library’s webhook
is
reached at.
Only one copy of the operator acts at a time. Each copy competes for
the Lease named library-operator in the operator’s namespace, and
only the copy that holds it watches, reconciles, and opens a bus
session. The Role grants that one Lease. A rollout starts the new
pod beside the old one. The old pod finishes its pass and releases the
Lease when it stops, and the new pod takes it within about 11
seconds. A copy that waits for the Lease still takes webhooks, and
it serves each one when it leads.
A copy that cannot renew the Lease, for example while the API server
restarts, stops writing 10 seconds after its last renewal and exits.
The kubelet restarts the container in the same pod. Because the
earlier holder was a process of the same pod, the new process takes the
Lease on its first read after the Lease expires, 30 seconds after
the last renewal. A copy in another pod waits 30 seconds from the
moment it first reads that Lease.
The ClusterRole is cluster-wide because a Library can be in any
namespace. Its grants, by object:
- This operator’s own resources: read on
Library,Catalog, andMetadataProvider, status writes on all three, andpatchonLibrary, for its finalizer. media-operator’sPlayerandMediaPreferences: read.Play: read, create, and patch.people.liken.sh’sPerson: read and patch, for one finalizer.Secrets:get, to run the reachability check of aMetadataProvider.Nodes,StorageClasses, andResourceClaimTemplates: read. The grant onResourceClaimTemplatesalso deletes, for the templates an earlier release created.- The claims, volumes, pods, and
Jobsit owns: read, create, and delete. The grant onPersistentVolumesis the one for volumes the operator writes for a claim on a per-node class. ServicesandEndpointSlices: read, create, and update, andServicesalso delete.ConfigMaps: read, create, and update. TheConfigMapof each screen namespace holds thePersonlist that every screen reads.Events: list, create, and patch.CronJobs: delete only, to remove theCronJobthat an earlier release created for eachLibrary.
This site serves the same files as raw YAML, so a clone is never
needed: libraries-crd.yaml
,
catalogs-crd.yaml
,
metadataproviders-crd.yaml
,
rbac.yaml
, and
operator.yaml
.
3. Declare a Catalog
A Library is namespaced, and each namespace that holds one has a
catalog of its own. The namespace is a boundary: every Library in it
writes into one catalog, and a screen shows that catalog. Declare
exactly one Catalog in the namespace before the first Library. A
Library in a namespace with no Catalog waits with the reason
NoCatalog, and a second Catalog blocks both.
apiVersion: library.liken.sh/v1alpha1
kind: Catalog
metadata:
name: media
namespace: media
spec:
storage: {}
An empty storage provisions a 1Gi claim on the default class.
Catalog
describes every field, and
The catalog
describes what the pod it creates
does.
4. Confirm it runs
kubectl -n liken-system get pods
kubectl -n liken-system logs deploy/library-operator
The operator’s log reports its first pass and the bus it reports over:
library.liken.sh: operating 0 libraries over bus.liken-system.svc:1883
A missing LIBRARY_BUS_ADDRESS or OPERATOR_NAMESPACE is an error at
startup, printed to the log, and the pod exits. The served
operator.yaml
sets both.
Now declare a library
. Once it is Ready,
the listing shows its counts and its phase:
$ kubectl -n media get libraries
NAME KIND TITLES ITEMS FILES WAITING SOURCES STATUS READY AGE
movies movies 0 0 0 0 Idle True 2m
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//library-operator/deploy?ref=<full 40-character sha>
images:
- name: ghcr.io/liken-sh/library-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/library-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 every Library first, and wait for each one to go. A Library
has a finalizer that the operator releases after a cleanup Job
removes its rows from the namespace’s catalog. If the operator’s
Deployment is gone, nothing runs that Job, and the Library stays
Terminating until a person patches the finalizer off.
kubectl -n media delete library movies
kubectl -n media delete catalog media
kubectl delete -k https://github.com/liken-sh/liken//library-operator/deploy?ref=<tag>
Deleting the Catalog deletes the catalog pod and the claim the
operator provisioned for it. Deleting the base deletes the
CustomResourceDefinitions, and with them every MetadataProvider.
The media claim and what is on it stay.