Mounting files into a group¶
spec.mounts puts a ConfigMap, a Secret or a PersistentVolumeClaim at a path
inside every pod of a group. Both ServerGroup and ProxyGroup have it.
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: bingo-solo
spec:
# ...
mounts:
- name: worlds # read-only: the group consumes maps
mountPath: /data/worlds
persistentVolumeClaim:
claimName: map-pool
- name: assets # a ConfigMap, as before
mountPath: /data/resources
configMap:
name: shared-assets
Exactly one source per mount. Naming none, or two, is refused by the API server rather than rendering an empty volume or quietly dropping one of them.
What a claim is for¶
extraPlugins is deliberately narrow: one claim, read-only, and the entrypoint
copies it into plugins/. extraFiles is
the same mechanism one directory up — its claim is copied into the whole
working directory, which is where a file that is not a plugin and that no mount
can reach belongs;
config/sponge/sponge.conf is the case that motivated it. Everything that
belongs anywhere else had nowhere to go — a world tree, a directory of assets
every server reads, the output of one group that another consumes. That is
what a claim mount carries.
It is still not a layered template system. There is no composition, no priority, no per-server rendering. A mount is one volume at one path, and assembling what goes in the volume is somebody else's job.
extraFiles and a claim mount share the two properties that an administrator
would otherwise discover the hard way. The source wins on every start — a
server that rewrites a file it was seeded with finds the claim's version again
next start, which is why a world does not belong in either. Nothing about
the volume reaches the pod hash — the operator holds a claim name, not a
filesystem, so a changed file reaches a server on its next start rather than
replacing one already running.
One file, not a directory¶
A mount lands the whole source at mountPath, so a path that names a file
gets a directory there — /data/bukkit.yml/ holding the ConfigMap's keys as
separate files. A server looking for that file finds a directory and reports a
parse error, which says nothing about a mount.
subPath is how a single file lands:
- name: bukkit
mountPath: /data/bukkit.yml
subPath: bukkit.yml
configMap:
name: server-files
A ConfigMap or Secret mounted through subPath does not update. Kubernetes
refreshes a projected volume in place, and a subPath mount is a bind of one
file out of it that the kubelet never re-points. Editing the ConfigMap changes
nothing in a running pod, and nothing reports that. Without subPath the file
does update, eventually and with no restart. That difference is why this is a
field you opt into rather than something inferred from the path looking like a
file.
Either way, the contents reach no digest, so editing a ConfigMap rolls nothing on its own.
Read-only unless it says otherwise¶
persistentVolumeClaim:
claimName: world-pool
writable: true
The field is writable and not readOnly so that the zero value is the safe
one: an omitted field, a field somebody has not heard of, and a field lost in a
hand-edited manifest all land on read-only.
Writable applies to the claim and to nothing else. A ConfigMap or Secret is mounted read-only by the kubelet whatever anybody writes, so the pod says so too.
Nothing here coordinates two writers. One group filling a pool that others read is the case this exists for. Two groups that both write the same claim get exactly what two processes writing one filesystem get, and the operator has no way to know which of them meant to.
The claim must be ReadWriteMany¶
The same rule extraPlugins
follows, for the same reason and with the same refusal: every pod of the group
mounts it, they are spread across nodes, and a ReadWriteOnce claim would
leave the second one Pending on a scheduling error about volume affinity with
nothing naming the claim. The operator refuses it up front instead —
Accepted=False, reason MountVolumeUnusable, with the claim's actual access
modes in the message. A claim that does not exist is refused the same way.
The rule does not soften for a group that runs one replica today, because
maxReplicas is raised by edits that have nothing to do with storage.
It needs --allow-mount-volumes¶
A claim-backed mount needs its own switch, --allow-mount-volumes — not
--allow-plugin-volumes, which governs spec.extraPlugins and, as of 0.2.x,
only that. A group naming a claim on an installation with
--allow-mount-volumes off is refused with MountVolumesDisabled and a
message pointing at the operator's arguments rather than at the storage, which
is fine.
ConfigMap and Secret mounts are not gated. They carry configuration an administrator wrote, not a filesystem somebody filled.
The flag is not a security boundary: a PersistentVolumeClaim is a namespaced
object in the same trust domain as the group naming it, so the switch stops
nobody who was not already stopped. What it buys is an operator being able to
say "this installation mounts no claim" and have that be a fact rather than a
convention.
Reserved paths¶
A mount may not land on the paths the operator uses itself. /var/run/spawnery
and /etc/spawnery are refused along with anything nested inside them or above
them: a mount at /var/run/spawnery/token would shadow the agent's own
credentials, and the server would fail to authenticate with nothing naming the
mount. /data and /tmp are refused only as exact matches — mounting inside
/data is the ordinary way to add files, which is why the example above works.
/data/plugins is refused; that is what extraPlugins is for.
/data/config is refused too, at it and inside it, and that one was
measured rather than reasoned. The kubelet creates a mount's parent directory
itself, root-owned and group-read-only:
/data drwxrwsrwx 0 10001 ← fsGroup makes the volume root writable
/data/config drwxr-sr-x 0 10001 ← the mount's parent does not inherit it
fsGroup with OnRootMismatch only ever touches the volume's own root, so the
container cannot write into that directory — and the first thing it tries to
write is spawnery-config's own paper-global.yml. The server never starts,
and the error names a file rather than a mount.
Nothing the operator can do makes it work: the ownership is the kubelet's, and
changing it would need a root init container, which is the one thing every pod
here is built not to have. So it is refused, with a message naming
spec.configOverlay — which is where server.properties, paper-global.yml
and paper-world-defaults.yml belong anyway.
Editing a mount replaces the group's servers¶
A mount shapes the pod, so it is in the group's pod digest, and adding one,
removing one or flipping writable rolls the group the way an image bump does.
The volume's contents reach no digest at all — the operator holds a claim
name, not a filesystem. Writing to the volume changes what the next server to
start reads, and changes nothing about the servers already running. That is the
same trade plugins.md describes, and the
remedy is the same: restart the group's servers when you want the new contents.