The rook-ceph addon narrows the RBAC of the Ceph CSI controller plugin and drops a cluster-wide COSI binding from its render (#267). The bootstrap gets a dry run and a per-entry filter: --dry-run, --render-dir, --no-diff and --only on lo bootstrap and lo provision --bootstrap (#268). The Lo (kind) driver gets two fixes: an unset spec.kubernetes.version now uses the default node image of kind (#269), and the default route of a kind node stays on the project network (#270). lo audit reads a dated Kubernetes support table (#258). Every change is in Go and in bash. Read Behaviour changes before you upgrade: a spec file must now hold one YAML document.
Behaviour changes
- One YAML document per spec file (#269). lo now refuses a
cluster.lok8s.yamlordeploy.lok8s.yamlwith more than one YAML document. This includes a trailing---with nothing after it, empty documents before the spec, and text after a...line. Before, the Go implementation read only the first document and the bash implementation read every document. So one file gave two different specs. The error names the lines to remove:text[error] clusters/example.dev/cluster.lok8s.yaml holds more than one YAML document. Remove line 8 (a '---' line after the spec).- One
---line before the spec and one...line after it stay valid. - These commands refuse the file before they change something:
lo provision,lo up,lo bootstrap,lo destroy,lo down,lo recover,lo build,lo deploy(also for the cluster spec thatspec.clusterRefnames),lo addonsandlo lint. lo statusshows the error in its Cluster section.lo auditreports the file as oneunknownfinding.lo useandlo doctorshow the driver of the domain as?.
- One
- The bash implementation needs newer tools. This applies only to a command that
lok8s.yamlroutes to bash (spec.implementation). The Go binary reads YAML and checks charts itself, so it needs neither tool.- yq v4.47.1 or later (#269). The bash readers of
spec.kubernetes.version(the Lo driver,lo lintandlo audit) use the yq flag--yaml-fix-merge-anchor-to-spec. An older yq stops the read with an error that says so. - helm v3.3 or a later 3.x on
PATHfor the chart dependency check of the bootstrap dry run (#268). Without it, a dry-run entry that renders a local chart with dependencies isinconclusive, and the run exits 2. A real bootstrap does not run this check.
- yq v4.47.1 or later (#269). The bash readers of
- A bootstrap entry cannot set the variables of lo (#268). The
env:of aspec.bootstrapentry may not setLOK8S_BOOTSTRAP_*,BOOTSTRAP_*,bootstrap_*,PATH_SECRETS,KUBECONFIG,KUSTOMIZE_PLUGIN_HOMEorHELM_PLUGINS. Such an entry is an error on a real run and on a dry run, and lo names the variable. An entry that the run skips staysskipped. Both implementations give theenv:of an entry only to the render (kustomize and envsubst), never to lo itself.
Security
The CSI controller plugin ClusterRole is least privilege (#267). This is the controller-side follow-up to the node plugin fix of v0.9.2 (#264). The controller plugin Deployment (ceph-csi and the external-provisioner, -attacher, -resizer and -snapshotter sidecars) got the full upstream ClusterRole of the
ceph-csi-driverschart: 29 rules for RBD and NVMe-oF, 27 for CephFS, 16 for NFS. Four rules were wider than any caller:secretsget/list/watch in all namespaces. The sidecars onlygetthe secret that the StorageClass names. Nothing lists or watches.configmapsget,serviceaccountsget andserviceaccounts/tokencreate. Only a KMS uses them.serviceaccounts/tokencreate alone mints a token for any ServiceAccount in the cluster.
Before, a takeover of the controller plugin pod gave read access to every Secret in the cluster and a token for any ServiceAccount. The addon now removes the four rules from the RBD, CephFS and NVMe-oF roles, and the one cluster-wide
secrets getfrom the NFS role.tokenreviewsandsubjectaccessreviewscreate stay. They are not escalation paths, and the snapshot-metadata sidecar needs them. Every PV, PVC, snapshot and attachment rule stays.- Secret access comes back namespaced. A new
Rolein therook-cephnamespace grantsgeton two Rook secrets by name:rook-csi-rbd-provisionerandrook-csi-cephfs-provisioner. The StorageClass parametersprovisionerandcontroller-expandand the VolumeSnapshotClass parametersnapshottername these secrets. ARoleBindinggives the Role to the controller plugin ServiceAccount of each driver, under the pinned and the chart default name. - The removal uses the guarded JSON patches of #264. A
teston the complete rule comes before eachremove. A chart bump that reorders, merges or widens a rule fails the render. - Go tests compare the patched roles with a committed fixture of the chart. An online test renders the pinned chart again in CI and fails when the fixture is stale.
rook-ceph-csi-kmsnow also binds its rules to the RBD controller plugin ServiceAccount. A KMS runs on both sides: the controller creates the key, the node unlocks it. The warning of that addon now covers the controller pod too.- Verify on a cluster:consoleBoth
$ SA=system:serviceaccount:rook-ceph:rook-ceph-rbd-csi-ceph-com-ctrlplugin-sa $ kubectl auth can-i list secrets --as="${SA}" --all-namespaces no $ kubectl auth can-i create serviceaccounts/token --as="${SA}" no $ kubectl auth can-i get secrets/rook-csi-rbd-provisioner -n rook-ceph --as="${SA}" yesnolines readyeson a cluster that bootstrapsrook-ceph-csi-kms.
The COSI ClusterRoleBinding leaves the render (#267). The rook-ceph chart ships
ClusterRoleBinding/objectstorage-provisioner-role-binding. It gives theobjectstorage-provisionerServiceAccountsecretsget, create, update and delete in every namespace. The addon deploys noCephCOSIDriver, and no pod runs as that ServiceAccount. The addon now drops the binding from its render ($patch: delete). The ClusterRole and the ServiceAccount stay.- A new cluster never gets the binding.
- The bootstrap applies without prune. An existing cluster keeps the binding until you delete it once (see Upgrade).
- A cluster that runs the COSI driver adds the binding again from its own target. The snippet is in the addons guide.
New
Bootstrap dry run and
--only(#268). A bootstrap applies everyspec.bootstrapentry again withkubectl apply --server-side --force-conflicts. That overwrites live drift in the addons it touches, for example a live-patched config or a replica count that an autoscaler owns. New flags onlo bootstrapandlo provision --bootstrapshow the change first and apply one entry at a time:bashlo provision --domain my-cluster.example.com --bootstrap --dry-run --render-dir /tmp/bootstrap lo provision --domain my-cluster.example.com --bootstrap --dry-run --no-diff lo provision --domain my-cluster.example.com --bootstrap --only cert-manager lo bootstrap --only cert-manager,envoy-gateway --dry-run--dry-runrenders every entry as a real run does and applies nothing. It uses the same project, value stack, envsubst, khelm and order, and the sameHOME, credentials and CA. It renders each entry two times. Then it asks the API server for a read-onlykubectl diff --server-side --force-conflictsof each entry.- The dry run prints one line per entry and a summary. The status is
unchanged,differs changed=N new=M,inconclusive (…),rendered,skippedorerror.restore.dgets its own line first. - Marks after the status:
(non-deterministic: N)for objects that change on every render (for example the CA and Hubble TLS Secrets of Cilium),(non-repeatable)for new secret material that the store does not hold, and(partial: …)for a part that the dry run did not run or read. - Exit codes, the same for both commands and both implementations: 0 when nothing would change, 1 only when a completed diff found a change, 2 for all other failures. An
inconclusiveentry, arestore.dfile that does not decrypt and a bad flag exit 2. --render-dir <dir>writesNN-<entry>.yamlandNN-<entry>.difffor each entry. The directory holds rendered Secrets in plaintext. lo creates it with mode 0700. It changes an existing directory that the group or others can read to 0700. lo refuses a path with a symlink in it.--no-diffrenders only and never contacts the cluster.--ignore-nondeterministicdiffs an entry without the objects that change on every render. lo still lists them.--only <entry>[,…]applies (or dry-runs) only the named entries, by entry name or addon name. It does not expanddependsOn. Only the edges among the named entries order the run. lo warns when an omitted entry is a prerequisite of a named one. An unknown or blank name is an error that lists the valid names and applies nothing.--onlyleavesrestore.dout.--with-restoreadds it again.- A dry run skips the infrastructure gate and the steps after the bootstrap in
lo provision(the cluster registration, the inventory, gitops). It ignores--force.
What the dry run protects against. The dry run prevents accidental side effects of legitimate addon code and configuration. In a dry run, lo and its built-in generators never apply anything and never write new state:
- the secrets plugin keeps new or changed values in a store of this run only,
- the cert generator never writes
CAROOT, - the Lo driver never writes
.registries.json, - lo never ejects a framework addon.
lo refuses executable addon code unless you pass
--allow-generators. This includes an exec plugin other than the Secret and ChartRenderer of lok8s, a KRM exec or container function, a remote base, ahelmChartschart that is not inchartHome, a build of chart dependencies, a helm plugin and abash:secrets generator.--allow-generatorsruns this code as trusted, with the side effects of a real run.What the dry run does not protect against. The dry run is not a security boundary against a malicious addon. Such an addon can still run code during a dry run, and the real bootstrap runs the same addon with full credentials. The helm cache can get new charts: khelm downloads a remote chart into the helm cache of your
HOME, as a real run does. The full table is in Deploying to Clusters.Chart dependency check. khelm can build the dependencies of a local chart into the chart directory. A dry run does not render such a chart. It checks, as Helm does, that the chart directory holds every dependency. If not, the entry is
inconclusive (chart dependencies not vendored). The Go implementation uses the Helm v3.21.2 library for the check. The bash implementation runshelm dependency listandhelm lint, both read-only.
Fixes
- An unset
spec.kubernetes.versionuses the default node image of kind (#269). Before, a Lo spec without the version renderedimage: "kindest/node:null"for every node.lo provisionthen failed atkind createwithmanifest for kindest/node:null not found. At that point it had already created the project docker network and the registry containers. Now the kind config names no image for an unset version, and kind uses the default node image of its release.- One reading rule serves the Lo driver,
lo lintandlo audit, in both implementations. - Unset: the key is missing or null (
~), or the value is""or the string"null". - Valid: any OCI image tag, 1 to 128 letters, digits,
_,.and-, with no.or-first. An optional@sha256:<64 hex>digest can follow.v1.31.4,latestand the tag of akind build node-imageimage all work. - Refused: a value that is not a tag, for example
kindest/node:v1.31.4. A shape that can hold a version but that the reader cannot read is an error, never unset. Examples are a merge cycle on the path, a version that is a map or a list, and a value with a control character. The error names the next action. - The reader resolves YAML aliases and merge keys as yq does. It reads only the path to the value, so an alias bomb in another part of the file costs nothing.
- In the bash implementation, each version read has a time limit of 10 s. It also has a memory limit of 256 MiB (
ulimit -d) where the operating system enforces it. The time limit uses no GNUtimeout, so it holds on macOS too. lo provisionchecks the version before it creates a VM, a docker network or a container.lo lintreports the same problems for aLocluster spec.lo auditreads the version with the same rule. ALocluster without a version is a pass.LOK8S_SPEC_KUBERNETES_VERSIONis now empty for the string"null".
- One reading rule serves the Lo driver,
- The default route of a kind node stays on the project network (#270). With shared registries (
spec.registries.shared.enabled), a kind node is on two docker networks: the project network andlok8s-registries. Docker takes the default route from the network with the highest gateway priority. Between equal priorities, it takes the network whose name sorts first. Before, both networks had priority 0. So a project network whose name sorts afterlok8s-registrieslost the default route. Traffic from the node to a service then left with the wrong source address.rp_filterdropped part of the replies, and the Cilium agent could not reach the API server at10.96.0.1.- On Docker 28 or later (client and daemon),
lo upconnects a node to the registry network withdocker network connect --gw-priority -1. The project network keeps priority 0 and the default route, also after a node restart. - On an older Docker,
lo upconnects without the flag. Then it moves the default route in the node:bashThis move does not survive a node restart (see Known limitations).ip route replace default via <project gateway> dev <project interface> lo upalso checks each node of the cluster that is already on the registry network, also whenshared.enabledisfalseor not set. It moves a wrong route and prints one[warn]line for each node.- To check a node, run
docker exec <node> ip route show default. The gateway must be the gateway of the project network.
- On Docker 28 or later (client and daemon),
lo auditreads a dated Kubernetes support table (#258). Thek8s-version-supportcheck read a static list of supported minors (1.34 1.35 1.36). A cluster on Kubernetes 1.37 got a "may be stale" warning, and 1.34 would keep its pass after its end of life. Now both implementations hold one row per minor with the date its upstream patch support ends:text1.37:2027-10-28 1.36:2027-06-28 1.35:2027-02-28 1.34:2026-10-27 1.33:2026-06-28- A minor is supported while its date is in the future. So 1.34 changes from pass to fail on 2026-10-27 with no code change. A dev cluster gets a warn.
- When every row is past its date, the check warns that the table is out of date.
- A new minor still needs a new row in both tables. A test fails when the Go table and the bash table differ.
_AUDIT_TODAY(yyyy-mm-dd) pins the day of the check. The tests use it.
- lo never writes through a symlink in a staged addon (#268). An addon that stacks values renders from a copy. lo now writes the files it changes in the copy (
chart.yaml,values.inline.yaml,values.merged.yaml) as new private files. The copy keeps its symlinks, and a file that a symlink points to stays unchanged. This applies to a real run too.
Changes
- The core
lobinary now links the Helm v3.21.2 library for the chart dependency check of the dry run. The binary grows by about 0.4 MB. - Dependencies: bubbletea v2.1.0, bubbles v2.2.1, lipgloss v2.0.6 and the MCP go-sdk v1.8.0. CI: actions/checkout v7.0.1, actions/setup-node v7.1.0, docker/login-action v4.6.0, docker/build-push-action v7.4.0, golangci-lint-action v9.3.0.
Upgrade
- Check your spec files first. Run
lo lintwith v0.10.0. It names each spec file with more than one YAML document and the lines to remove. Delete those lines. Until then, every command refuses that domain. - The bash implementation. A project that routes commands to bash installs yq v4.47.1 or later. For the bootstrap dry run it also needs helm v3.3 or a later 3.x. A project with its own bash tree in
.lok8s/(lo assets eject bash) runslo assets update bashto get the bash twin of each change. - An ejected copy of the rook-ceph addons.
loejects an addon into.lok8s/addons/<name>/on first use and never overwrites it. A local copy wins over the embedded one.lo assets diff addons/rook-cephshows the changed files.lo assets update addons/rook-cephapplies them when your copy is untouched. Do the same foraddons/rook-ceph-csi-kmswhen the cluster bootstraps it. - StorageClasses with their own secret. Before you apply #267, check each Ceph StorageClass. A StorageClass whose controller-side secret has another name, or lives outside the
rook-cephnamespace, loses every controller call that needs the secret. These calls are provision, delete, attach, detach, expand and snapshot. The sidecar gets a 403 and a new PVC staysPending. Mounted volumes keep working. Add aRoleand aRoleBindingfor that secret from your own target. The snippet is in the addons guide. The same applies to aVolumeGroupSnapshotClassorVolumeReplicationClassthat names another secret. - Apply #267 to an existing cluster: dry run first. The narrowed roles and the new Role arrive with the next bootstrap apply.
- Preview the bootstrap. Expect
differsforrook-cephand exit code 1:bashReadlo provision --domain <domain> --bootstrap --dry-run --render-dir /tmp/bootstrapNN-rook-ceph.diff. It shows the narrowed controller plugin ClusterRoles and the new Role and RoleBinding. The render directory holds Secrets in plaintext, so delete it when you are done. - When other entries differ and you do not want to change them now, apply only the rook-ceph entries. Add
rook-ceph-csi-kmsto the list when the cluster bootstraps it:bashOtherwise runlo provision --domain <domain> --bootstrap --only rook-cephlo provision --domain <domain> --bootstrap(orlo bootstrap --domain <domain>). - Until the RoleBinding is in the cluster, a provisioner call can get a 403 for a few seconds. The sidecars try again. No pod restarts.
- A cluster that does not run the COSI driver deletes the old binding once, by hand. Then it checks the result:console
$ kubectl delete clusterrolebinding objectstorage-provisioner-role-binding $ kubectl auth can-i get secrets -n kube-system \ --as=system:serviceaccount:rook-ceph:objectstorage-provisioner no - Run the three
kubectl auth can-ilines from Security. Then create a test PVC for each Ceph StorageClass. It must bind, attach, expand, take a snapshot and delete.
- Preview the bootstrap. Expect
- Shared registries (#270). Run
lo uponce for each cluster whose nodes are onlok8s-registries. It moves a wrong default route. On Docker 28 or later, a node that an older lok8s attached gets only the temporary route move (see Known limitations).
Known limitations
- The route move of #270 is temporary in two cases. On a Docker older than 28, the move in the node does not survive a node restart, for example a host reboot. On Docker 28 or later,
lo updoes not change the priority of an endpoint that exists. A node that an older lok8s attached at priority 0 gets only the temporary move too (#273). After a node restart, runlo upagain. For a permanent fix on Docker 28 or later, detach the node (docker network disconnect lok8s-registries <node>) and runlo up, or recreate the cluster. - The route repair supports only the Docker engine. lok8s does not support a
dockercommand from Podman for it. - KRM functions in a dry run. Without
--allow-generators, kustomize skips a KRM exec function with no error. Its objects are missing from the preview, and the entry is(partial: KRM function skipped). On macOS and BSD, the bash implementation cannot read the KRM hint and prints nopartialmark (#276). - The chart dependency check can differ between the implementations. A helm v3 release other than v3.21.2 can give a different dependency status. Then one implementation is
inconclusivewhere the other renders.
Follow-up
- #265: feature-specific, namespaced grants for
rook-ceph-csi-kmsinstead of the upstream rule set. - #272: the rest of the unset-version work. Remove the unused
--kubernetesflag, stop two readers that send the string"null", and move the version reader to a neutral package. - #273: on Docker 28 or later, move a node that an older lok8s attached to gateway priority -1.
- #275: a bats provision test reaches the real
dockerbinary. - #276: small follow-ups from the review of the dry run: portable
realpathin the bash KRM hint, clearer status labels and messages. - #277: one helper for the bounded yq reads of the bash implementation, and cheaper merge-key reads.
Commits
4d52e09,da1912d,e46cca5,f36f5d7,ed75603,7d8156e,e999a98,76b936f,703fb8echore(deps), ci(deps): nine dependency bumps (#209 to #216, #235)96f4da1fix(audit): read a dated Kubernetes support table, in both implementations (#258)bfcbe50fix(rook-ceph): least-privilege ClusterRole for the CSI controller plugin (#267)f86ccfefix(lo): keep the default route of a kind node on the project network (#270)2bd98b9feat(bootstrap): --dry-run, --render-dir, --no-diff, --only (#268)10b8d6afix(lo): an unset spec.kubernetes.version uses kind's default node image (#269)