☰ 🥷 Datalayer Operator
Datalayer Operator ensures that the requested services and resources are available. Datalayer Operator is a Kubernetes operator that constantly monitors the system and takes action so that the desired state is met on the Kubernetes cluster. It is responsible for managing the state of the Datalayer services.
- 🛣️ Manages the Routes to secure and direct the incoming request to the correct service or pod.
- 🐋 Manages the Docker Images building and publication in the Docker registry.
- 💼 Manages the Runtime Contents.
- 🌳 Manages the Runtime Environments
- 🎱 Manages the Runtime Pools.
- 📎 Provisions the Volumes and mounts the Contents attachments — Home Folders, Volumes, local bridges — into the Runtime pods, and tells Contents how each came up.
Please note that the Runtimes Service is responsible to deliver the ⚪ Runtimes to the user, so it is not the duty of the Datalayer Operator to serve Runtimes.
A subset of the Operator methods is exposed via REST endpoints for internal consumption (not visible from external clients).
Deploy Datalayer Operator
- Plane
- Helm
- Terraform
plane up datalayer-operator
export RELEASE=datalayer-operator
export NAMESPACE=datalayer-runtimes
helm upgrade \
--install $RELEASE \
oci://${DATALAYER_HELM_REGISTRY_HOST}/datalayer-charts/operator \
--create-namespace \
--namespace $NAMESPACE \
--set operator.image="${DATALAYER_DOCKER_REGISTRY}/operator:0.1.1" \
--set operator.certificateIssuer="letsencrypt" \
--set operator.crds="true" \
--set operator.cloudCredentials.enabled="true" \
--set operator.cloudCredentials.create="true" \
--set operator.cloudCredentials.secretName="${RELEASE}-cloud-credentials" \
--set operator.cloudCredentials.aws.accessKeyId="${AWS_ACCESS_KEY_ID}" \
--set operator.cloudCredentials.aws.secretAccessKey="${AWS_SECRET_ACCESS_KEY}" \
--set operator.cloudCredentials.aws.defaultRegion="${AWS_DEFAULT_REGION}" \
--set operator.cloudCredentials.azure.tenantId="${AZURE_TENANT_ID}" \
--set operator.cloudCredentials.azure.clientId="${AZURE_CLIENT_ID}" \
--set operator.cloudCredentials.azure.clientSecret="${AZURE_CLIENT_SECRET}" \
--set operator.cloudCredentials.azure.subscriptionId="${AZURE_SUBSCRIPTION_ID}" \
--set operator.cloudCredentials.azure.resourceGroup="${AZURE_RESOURCE_GROUP}" \
--set operator.env.DATALAYER_DOCKER_REGISTRY="${DATALAYER_DOCKER_REGISTRY}" \
--set operator.env.DATALAYER_RUN_HOST="${DATALAYER_RUN_HOST}" \
--set operator.env.DATALAYER_OPERATOR_API_KEY="${DATALAYER_OPERATOR_API_KEY}" \
--set operator.env.DATALAYER_IAM_HOST="${DATALAYER_IAM_HOST}" \
--set operator.env.DATALAYER_IAM_API_KEY="${DATALAYER_IAM_API_KEY}" \
--set operator.env.DATALAYER_AUTHZ_ENGINE="${DATALAYER_AUTHZ_ENGINE}" \
--set operator.env.DATALAYER_CERT_ISSUER="${DATALAYER_CERT_ISSUER}" \
--set operator.env.DATALAYER_INGRESS_CLASS_NAME="${DATALAYER_INGRESS_CLASS_NAME}" \
--timeout 5m
cd terraform
terraform init
terraform apply
./generated/clouder-Kubeadm-setup.sh
export KUBECONFIG=~/.clouder/kubeadm/<cluster-name>/kubeconfig
./generated/services/deploy-datalayer-operator.sh
After the upgrade: re-apply the Runtime Contents and the Environments
Since Environments select their Runtime Contents by uid, the Operator
spawns nothing from an Environment still applied in the earlier shape
(contents: [{name: ...}]), and nothing from one whose uids match no
RuntimeContent in the cluster — every pool pod on it is refused before it
is created. Upgrading the image is therefore not the whole upgrade. Apply the
definitions first (each carries its spec.uid), then the Environments:
kubectl apply -n datalayer-runtimes -f plane/etc/specs/runtime-contents/
kubectl apply -n datalayer-runtimes -f plane/etc/specs/runtime-environments/prod/
kubectl get runtime-contents -n datalayer-runtimes -o custom-columns=NAME:.metadata.name,UID:.spec.uid,TYPE:.spec.type
kubectl get runtime-environments -n datalayer-runtimes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.contents[*].uid}{"\n"}{end}'
Every content must show a UID and every Environment a list of uids; a
name in an Environment's contents is the old shape.
When no pool pod comes up. The Operator now says why, at startup and on every reconcile, on its own logger:
plane logs datalayer-operator | grep -E "startup_check|pool_reconcile|add_runtime_pod"
[startup_check] Environment 'X' still selects its contents by name … or
… selects a Runtime Content the cluster cannot deliver … names the spec to
re-apply; [startup_hydrate] runtime contents (name: uid) lists what the
cluster defines; [add_runtime_pod] FAILED carries the traceback of a spawn
that failed for any other reason. The pool retries a minute later, so a
re-applied spec is picked up without a restart.
Cloud Credentials (AWS/Azure)
For Operator scaling/executor workflows, credentials are consumed through a Kubernetes Secret referenced by the Operator Deployment.
plane up datalayer-operatorconfigures Helm values to create and mount${RELEASE}-cloud-credentials.plane down datalayer-operatorremoves the release and the generated${RELEASE}-cloud-credentialsSecret.- If you manage credentials outside Plane, keep
operator.cloudCredentials.enabled=true, setoperator.cloudCredentials.create=false, and pointoperator.cloudCredentials.secretNameto your existing Secret.
Recommended Azure Flow (Clouder Kubeadm)
For clusters created with clouder kubeadm, the recommended flow is to generate
a cluster-scoped Helm values JSON and let Plane use it automatically.
- Ensure Azure is configured in Clouder.
clouder azure configure
- Generate operator cloud credentials JSON for your cluster.
clouder azure helm-values --cluster my-cluster
By default this creates:
~/.clouder/kubeadm/r1/datalayer-operator-azure.json
- Deploy the operator.
plane up datalayer-operator
plane/datalayer_plane/sbin/up.sh now checks the current kube context and, when
present, automatically applies:
~/.clouder/kubeadm/<current-context>/datalayer-operator-azure.json
- Optional: override the file path explicitly.
export DATALAYER_OPERATOR_AZURE_VALUES_FILE=/path/to/datalayer-operator-azure.json
plane up datalayer-operator
- Optional: deploy with Helm directly.
helm upgrade \
--install datalayer-operator \
oci://${DATALAYER_HELM_REGISTRY_HOST}/datalayer-charts/operator \
--namespace datalayer-runtimes \
--create-namespace \
--values ~/.clouder/kubeadm/r1/datalayer-operator-azure.json
The generated JSON includes:
operator.cloudCredentials.azure.tenantIdoperator.cloudCredentials.azure.clientIdoperator.cloudCredentials.azure.clientSecretoperator.cloudCredentials.azure.subscriptionIdoperator.cloudCredentials.azure.resourceGroup
- Plane
- Helm
plane ls
helm ls -A
Check the availability of the Datalayer CRDs.
kubectl get crd runtime-contents.datalayer.io
kubectl explain runtime-contents.spec
kubectl get crd runtime-environments.datalayer.io
kubectl explain runtime-environments.spec
kubectl get crd runtime-pools.datalayer.io
kubectl explain runtime-pools.spec
Check the availability of the Datalayer Operator Pods.
kubectl get pods -n datalayer-runtimes -l app=operator
Check the logs of the Datalayer Operator Pods.
kubectl logs -n datalayer-runtimes -l app=operator -f
Runtime Contents and Environments
A RuntimeContent defines something an Environment can bring into a runtime: a git repository, a subpath of the platform shared filesystem (nfs), or a S3 bucket. Every content carries a stable identity, spec.uid (a ULID assigned once, when the content is defined), and Environments select contents by uid, never by name — a content can be renamed or re-described without every Environment that uses it quietly pointing elsewhere.
apiVersion: datalayer.io/v1
kind: RuntimeContent
metadata:
name: jakevdp-sklearn-tutorial-content
namespace: datalayer-runtimes
spec:
uid: 01M0YX0MXYRP0Q29YWYRE5THZE
type: git
description: Jake VanderPlas scikit-learn tutorial
source: https://github.com/jakevdp/sklearn_tutorial.git
revision: 5098cee2a638c56c311aca0c18987e407fe127fd
| Field | Meaning |
|---|---|
uid | The identity Environments select by. Required. |
type | git, nfs or s3. |
source | The repository URI (git) or the bucket name (s3). |
subPath | The subpath of the shared filesystem (nfs). |
revision | The commit sha a git content is checked out at. A git content without a revision is refused at launch: a tutorial that changes under a user is not the tutorial they were promised. |
sha256 | Optional. The digest of git archive --format=tar <revision>; when set, the checkout is verified and the runtime pod fails to start on a mismatch. |
permissions | The content's own access, ro (default) or rw. |
secret | The Kubernetes Secret holding the bucket credentials (s3). |
An Environment selects what it brings, and where:
apiVersion: datalayer.io/v1
kind: RuntimeEnvironment
spec:
contents:
- uid: 01M0YX0MXYD8YZVMJW016KTB4M # nfs-models-oss-content
mount: /home/jovyan/models/datalayer-oss
permissions: ro
- uid: 01M0YX0MXYRP0Q29YWYRE5THZE # jakevdp-sklearn-tutorial-content
mount: /home/jovyan/tutorials/sklearn-tutorial
The permissions of a selection narrows the content's own access and is never wider than it; absent means the content's own. The name after # is a comment for the reader — the Operator resolves names, types and revisions from the definitions when it lists Environments.
At launch, the Operator:
- resolves every selected uid against its index of
RuntimeContentdefinitions (the kopf cache, with the API server as fallback). A uid nothing defines, or a git content with no revision, refuses the launch with an error naming the uid — a runtime never starts with less than its Environment promised; - mounts only the selected entries. On a pod spawned for the launch, each type is rendered into the spec: a
gitcontent is checked out at its pinned revision by an init container (alpine/git) that fails the pod when the revision is missing or the digest does not match; anfscontent is a subpath of the shared filesystem claim; as3content is its Datashim claim. On a pooled pod none of that is possible — the spec was fixed before the Environment was known — so the same three become gateway grants (git,shared-folderornfs,cloud-storage) applied after the pod is running. A content the gateway cannot deliver refuses the pool and spawns instead, so a runtime never starts short of what its Environment promised; - publishes the resolved contents on the pod, in the
contents.datalayer.io/attachmentsannotation the Contents attachments already use, asdelivery: environmententries carryingmount_path,modeandprovider_resource_id(the RuntimeContent uid). The sandbox manifest therefore lists everything the runtime mounts, wherever it came from. These entries have no Contents attachment record behind them: nothing is prepared, reported or revoked for them.
A platform administrator changes what an Environment brings through the Runtimes gateway (PUT /api/runtimes/v1/environments/<name>/contents), which proxies to the Operator's PUT /api/operator/v1/environments/<name>/contents with a body of the form {"contents": [{"uid": "...", "mount": "/home/jovyan/...", "permissions": "ro"}]}. The Operator resolves every uid before writing, patches the RuntimeEnvironment spec, and answers 422 naming a uid nothing defines, a git content nobody pinned, or a mount path that is not a unique absolute path.
kubectl get runtime-contents -n datalayer-runtimes
kubectl get runtime-environments -n datalayer-runtimes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.contents[*].uid}{"\n"}{end}'
Contents attachments
The Contents service keeps the catalog — what an account may bring into a sandbox — and the Operator is the one process that turns an attachment into something a pod can read. Contents never talks to Kubernetes; the Operator never decides who may attach what. Three things are the Operator's, in the order they happen.
Volumes: the PersistentVolumeClaims
A Volume source in the Contents catalog is a PVC in datalayer-runtimes,
made by the Operator on Contents' request. The request is not inline any
more: creating a Volume source records a volume-provision operation that the
Contents worker claims and drives against the Operator's REST endpoints —
POST /api/operator/v1/volumes to create, GET /volumes/{uid} until the
claim is Bound, DELETE /volumes/{uid} as the compensation when every
attempt is spent. The calls carry the owner in x-forwarded-user and the
Operator's own key (DATALAYER_OPERATOR_API_KEY); the Volume's uid is the
source's uid, so a claim is always traceable to its catalog entry. A source
is trusted — ready, mountable — only once the claim is bound. When a pod
mounts a Volume the Operator records the assignment on the PVC and clears it
when the runtime is deleted, so a Volume in use is never re-attached elsewhere.
kubectl get pvc -n datalayer-runtimes
At launch: rendering the attachments
Runtimes launches a sandbox that has attachments with
POST /api/operator/v1/runtimes carrying content_attachments — the
attachments it just had Contents prepare for that pod name (see
Runtimes). The Operator refuses
the launch (4xx) when an attachment is not in preparing state, names
another sandbox or another provider than datalayer, or when two of them
share a mount path or one is not an absolute path. Then, per delivery:
| Attachment | What the pod gets |
|---|---|
Home Folder (a files source, stamped by Runtimes) | subpaths of the platform shared filesystem claim (DATALAYER_SHARED_FS_VOLUME_CLAIM_NAME), one per membership the caller has — their own, each organization, each team — at /home/jovyan/<handle>; an init container creates the folders and hands them to the runtime user. The list of memberships is resolved by Runtimes from IAM and travels on the pod as runtime-pools.datalayer.io/home-folder-mounts. Contents reads the same claim at the same path, which is why the two services' mount paths must agree. With the Node Mount Gateway on, the same folders reach the same paths through a pooled pod instead — see below |
mount of a Volume | the Volume's PVC, at the attachment's mount_path (default /home/jovyan/volumes/<volume name>), read-only when the mode is ro. A Volume of another account refuses the launch; a required Volume that cannot be found refuses it, an optional one is dropped |
local-bridge (a person's own folder over the relay) | an inline csi: volume of the local.csi.datalayer.io driver — content-bridge-<n>, attributes bridge-uid, sandbox-uid, mount-mode, relay-url — with nodePublishSecretRef pointing at the Secret bridge-<bridge uid> (key mount-token, label contents.datalayer.io/bridge-uid). The Secret is created right after the pod and owned by it, so the token goes when the pod goes; the pod spec names the bridge and never carries the token. The Local CSI driver on the node dials the relay and binds the filesystem before the container starts. An attachment Contents did not prepare a bridge session for is reported failed with BRIDGE_NOT_PREPARED: a required one refuses the launch, an optional one is left out — never rendered as an empty directory |
environment (brought by the Environment, see above) | the pinned git checkout, the shared-filesystem subpath or the Datashim claim; no Contents record, nothing reported |
| anything else | refused when required, dropped when optional |
The rendered list is written on the pod as the
contents.datalayer.io/attachments annotation. It is the record: a pod spec
rebuilt from it renders the same mounts, the Operator's cache is rebuilt from
it at restart, and the runtime's API description lists what the pod actually
mounts rather than something re-derived from billing labels.
kubectl get pod -n datalayer-runtimes <pod> -o jsonpath='{.metadata.annotations.contents\.datalayer\.io/attachments}'
kubectl get secret -n datalayer-runtimes -l contents.datalayer.io/bridge-uid
Reporting to Contents
The Operator tells Contents how each attachment came up, with
PATCH /api/contents/v1/attachments/{uid}/status under
DATALAYER_OPERATOR_API_KEY (the attachments:status scope):
readyonce the pod runs — for the Home Folder and the Volumes. Alocal-bridgeis not reported ready by the Operator: the volume is there, but the bytes are readable only once the bridge connects, and that is the relay's to say;failed, with a code and a message, for what could not be mounted. A required attachment whose report cannot be delivered ends the launch: the runtime is deleted (attachment_failed) and the caller gets502;revokedwhen the runtime is deleted, after the Volume assignments are cleared — so the catalog never lists a sandbox that is gone;readyagain for every attachment of a pod the Operator finds when it (re)starts and indexes the cluster, so a restart of the Operator does not leave attachments stuck inpreparing.
Environment entries are skipped in all four: nothing prepared them and there is no record to update.
Configuration
| Variable | Meaning |
|---|---|
DATALAYER_CONTENTS_URL | Where the Operator reports (operator.env in the chart; default the in-cluster datalayer-contents-svc, https://r1.datalayer.run in the code). |
DATALAYER_OPERATOR_API_KEY | The Operator's identity to Contents — one key per calling process, so it can be rotated without touching Runtimes' and the audit trail says which acted. Required as soon as one attachment exists: without it the report raises. |
DATALAYER_SHARED_FS_VOLUME_CLAIM_NAME | The shared filesystem claim the Home Folders live on. Contents mounts the same claim at the same path (DATALAYER_SHARED_FS_MOUNT_PATH); a mismatch makes a path recorded by one unresolvable in the other. |
DATALAYER_NODE_MOUNT_GATEWAY_ENABLED | Whether pooled pods carry the Node Mount Gateway and a Home Folder launch is served from the pool. Off by default. Turn it on after the node agent is deployed: pods would otherwise carry the volume and wait for mounts nobody makes. |
DATALAYER_NODE_MOUNT_GATEWAY_READY_TIMEOUT_SECONDS | How long the Operator waits for the node agent to report a grant applied (default 20). Past it the launch is reported without its folders, loudly. |
DATALAYER_NODE_MOUNT_GATEWAY_MAX_MOUNTS_PER_POD | The most folders one sandbox may be granted (default 32). |
Pool or its own Pod: which launches pay for a cold start
A Pod's volumes are fixed when it is created, so a launch that mounts
something used to need a Pod of its own — scheduling, an image pull, the init
containers and a Jupyter startup probe — while a launch that mounts nothing
cost one PATCH against a prewarmed Pod.
With DATALAYER_NODE_MOUNT_GATEWAY_ENABLED that is no longer true of the Home
Folder. What decides, per launch:
| The launch | Where the Pod comes from |
|---|---|
| Mounts nothing | The pool. |
Home Folder only (the home-folder capability, or a files attachment), gateway on, a pooled Pod carrying the gateway | The pool, with a hot attach: the Operator writes the mount set on the Pod and the node agent binds it in. |
| Home Folder only, gateway off | Its own Pod. |
| A Volume, a Local Mount, or anything else the gateway cannot serve | Its own Pod, whatever the gateway setting. A Volume has a PVC nothing staged on the node; a Local Mount is an inline CSI volume fixed at creation. |
| Home Folder only, gateway on, but the pool holds only Pods built before it | Its own Pod. Handing out a pre-gateway Pod would start a runtime whose folders never arrive. |
The hot-attach path writes runtime-pools.datalayer.io/node-mount-gateway-mounts on the
Pod and waits for the node agent's answer on …/gateway-mounts-ready — the
hash it asked for, not merely a recent answer — then asks the companion to
link /home/jovyan/<handle> to the gateway path. The mount set is resolved
from the memberships Runtimes resolved against IAM with the caller's own
credentials, never from what the request named. The whole node side is on the
Local CSI driver page.
Endpoints, for a runtime that is already running:
GET /api/operator/v1/runtimes/{runtime}/mounts # granted, and what is applied
POST /api/operator/v1/runtimes/{runtime}/mounts # mount the caller's home folders
DELETE /api/operator/v1/runtimes/{runtime}/mounts/{target} # take one out
POST takes attachment uids, never records: Runtimes resolves and
prepares them through Contents with the caller's credentials, exactly as at
launch, and drops any content_attachments a request carries. A client that
could hand over an attachment record could hand over a bridge session it does
not own.
It answers 409 when the gateway is off in this deployment or the Pod was
created without it, 422 for an attachment the gateway cannot serve, and
504 when the node agent did not apply the mounts in time — never 200 for a
mount nobody confirmed.
What can be attached to a running Runtime: a Home Folder, a Local Mount,
and a mounted bucket — the last two only where their path is under
/home/jovyan. A Volume cannot — its PVC is not
staged on the node — and neither can a Local Mount asking for any other
absolute path, because the gateway delivers under the home folder. A
creation-time mount can be anywhere and still can; the refusal names the
reason (LOCAL_BRIDGE_PATH_UNSUPPORTED) rather than mounting somewhere the
caller did not ask for. A hot-attached Local Mount's token goes into the
Pod-owned bridge-<uid> Secret, which the Operator creates before it
writes the grant, so the agent never looks for a Secret that is not there. A
mounted bucket's STS session goes into mount-<attachment uid> the same way.
Keeping a mounted bucket alive
A bucket's session expires and a runtime does not. refresh_mount_sessions
runs in the Operator every
DATALAYER_MOUNT_SESSION_REFRESH_INTERVAL_SECONDS (60 by default, and off
entirely when the gateway is off): it finds every session Secret within ten
minutes of expiry by its
contents.datalayer.io/mount-attachment-uid label, asks Contents for a newer
one at POST /attachments/{uid}/mount-session, and replaces the Secret in
place. The node agent's credential endpoint re-reads it and the filesystem
never stops running — nothing is unmounted and no open file handle is
disturbed.
The same pass also heals a rebuilt Pod. A Pod spec rebuilt from its annotations carries the grant — writing the mount set on the Pod is the point of it — but not the session, which is deliberately never written there, and the Secret was owned by the Pod that is gone. So a grant with no Secret is treated as a session to mint: the loop finds it, mints one, and creates the Secret owned by the new Pod. The node agent retries the grant on its next reconcile and the bucket comes back. A Pod that is terminating is skipped — its Secret is gone because the Pod is going, and minting one would create a credential for something already leaving.
A Secret whose expiry cannot be read is refreshed anyway: a session nobody can date is one nobody can trust, and re-minting costs one API call. One that cannot be re-minted is logged and skipped; that mount keeps working until its session ends and then reports itself through the health path.
kubectl -n datalayer-runtimes get secret -l contents.datalayer.io/mount-attachment-uid
kubectl -n datalayer-api logs deploy/datalayer-operator | grep 'mounted bucket session'
Tear Down Datalayer Operator
If needed, tear down.
- Plane
- Helm
plane down datalayer-operator
export RELEASE=datalayer-operator
export NAMESPACE=datalayer-runtimes
helm delete $RELEASE --namespace $NAMESPACE