Plugins from a volume¶
An administrator fills a volume with plugin jars and their configuration, and every server or proxy of a group loads them. No image is rebuilt, no release is cut, and no fleet is rolled.
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: lobby
namespace: minecraft
spec:
# ...
extraPlugins:
claimName: minecraft-plugins
The same field exists on ProxyGroup.
Configuration that does not live under plugins/ — Sponge's
config/sponge/sponge.conf is the case that forced the question — is not what
this field is for. extraFiles is the same
mechanism one directory up, for exactly that case.
Turning it on¶
The operator refuses a group naming extraPlugins unless it was started with
--allow-plugin-volumes. The chart passes the operator's arguments through, so
this is a values edit and a restart of one Deployment.
A claim-backed spec.mounts entry has its own switch,
--allow-mount-volumes — until 0.2.x it shared this one, and that flag's name
never promised it. Each claim-consuming field has its own switch now:
--allow-plugin-volumes for extraPlugins, --allow-file-volumes for
extraFiles, and --allow-mount-volumes for a claim-backed mount.
None of the three is a security boundary, and nothing here will tell you it
is. A PersistentVolumeClaim is a namespaced object in the same trust domain
as the group that names it: anybody who can write one can write the other, so
the switch stops nobody who was not already stopped. What it is for is an
operator being able to say this installation runs no third-party plugins and
have that be a fact rather than a convention.
A group that names a claim on an installation with the switch off is refused
with Accepted=False, reason PluginVolumesDisabled, and a message naming the
flag — not the claim, because the claim is probably fine.
The claim must be ReadWriteMany¶
A group's servers are spread across nodes, and every one of them mounts this
volume. A ReadWriteOnce claim attaches to one node, so the second server
would sit Pending on a scheduling error about volume affinity — with nothing
naming the claim.
The operator refuses it instead: Accepted=False, reason
PluginVolumeUnusable, and a message carrying the claim's actual access modes.
The same refusal covers a claim that does not exist.
A single-replica group is refused too, and that is deliberate rather than an
oversight. ReadWriteOnce would work for it today. But maxReplicas is
raised by edits that have nothing to do with storage, and a group that worked
until somebody scaled it is a worse failure than one that never started.
On this project's own cluster that means Longhorn:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: minecraft-plugins
namespace: minecraft
spec:
accessModes: [ReadWriteMany]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
What Longhorn's RWX adds to the failure surface¶
Longhorn serves a ReadWriteMany volume through a share-manager pod that
exports NFS, which every consuming node then mounts. That is one more moving
part between the volume and a starting server than a ReadWriteOnce volume
has.
If the share-manager is down or being rescheduled, the mount hangs and the server does not start — and the pod's events name an NFS mount, not a plugin volume. Nothing here can change that; it is Longhorn's architecture. What this page can do is put the sentence in front of you before the symptom is.
Longhorn's own requirement for RWX is an NFSv4 client on every node. Its node objects report it:
kubectl -n longhorn-system get nodes.longhorn.io -o json |
jq -r '.items[] | "\(.metadata.name) \(.status.conditions[] |
select(.type=="NFSClientInstalled") | .status)"'
What lands where¶
On every start, each entrypoint copies the whole tree from the volume into
the server's plugins/ directory, and then copies the agent jar over it.
Jars and their configuration together, not jars alone. A plugin's configuration
lives at plugins/<Name>/config.yml, and a mechanism that carried one without
the other would leave every plugin at its defaults on an ephemeral group —
whose /data is an emptyDir and keeps nothing.
The volume wins on every start. A plugin that rewrites its own configuration at runtime loses that change when the pod is replaced. On an ephemeral group it would lose it anyway; on a persistent one this keeps the volume's contents authoritative instead of letting each server drift.
The agent jar wins over the volume. A spawnery-agent.jar placed on the
volume is overwritten by the one the image ships. Without that order, somebody
pinning an older agent would leave the operator talking to a version it never
published — and every object in the cluster would say the right thing.
Changing a plugin¶
Write to the volume, then restart the group's servers. Deleting the pods is enough; the group replaces them.
Nothing rolls on its own, and that is the point. The operator holds a claim name, not a filesystem, so nothing about the volume's contents can reach the pod hash — which is what lets you change a plugin without an image rebuild, a release, or a changeover. The cost is that saving a file changes nothing until you say so.
Adding or removing the extraPlugins field itself does move the pod hash and
roll the group, because the rendered pod really is different.
What this is not¶
- Not a plugin manager. Nothing installs, updates, resolves dependencies for, or version-checks anything. The tree is copied verbatim.
- Not per-server. The claim belongs to a group; every server in it gets the same tree.
- Not two-way. The copy runs one direction on every start.
- Not in any image. No third-party plugin ships in a Spawnery image, and this mechanism exists so none has to.
Files from a volume¶
extraFiles is the same mechanism one directory up. Its claim is copied into
the server's whole working directory rather than into plugins/, which is
where a file that is not a plugin and that no mount can reach belongs — Sponge
reading config/sponge/sponge.conf is the case that forced the question.
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: lobby
namespace: minecraft
spec:
# ...
extraFiles:
claimName: minecraft-files
The same field exists on ProxyGroup, and the claim carries the same
ReadWriteMany requirement as an extraPlugins claim, refused the same way and
for the same reason — with FileVolumeUnusable rather than
PluginVolumeUnusable, so that the message sends somebody to the field they
actually wrote.
It needs --allow-file-volumes, which is its own switch and not
--allow-plugin-volumes. In the chart that is operator.allowFileVolumes,
default false:
operator:
allowFileVolumes: true
A group naming a claim on an installation with the switch off is refused with
Accepted=False, reason FileVolumesDisabled, and a message naming the flag.
The volume wins on every start, exactly as it does for extraPlugins: a
file the server rewrote at runtime is replaced by the claim's version the next
time the pod starts. A world therefore does not belong in this claim — it
would be overwritten on every start. spec.storage and a claim-backed
spec.mounts entry are what carry one.
Nothing about the contents reaches the pod hash. Writing to the volume rolls nothing; the files reach a server on its next start, which somebody triggers by deleting the group's pods. Adding or removing the field itself does move the hash, because the rendered pod really is different.
Paths a claim may not carry¶
Three things write into a server's working directory on a start: the operator's
renderer, this copy, and the extraPlugins copy. Rather than an order that
quietly decides who wins, each entrypoint scans the claim before copying
anything and refuses to start if it carries a path one of the others owns.
| Path in the claim | Matches | Owned by | Refused on |
|---|---|---|---|
plugins |
the name and everything under it | extraPlugins |
Paper and Velocity |
server.properties |
that path exactly | the renderer | Paper only |
config/paper-global.yml |
that path exactly | the renderer | Paper only |
config/paper-world-defaults.yml |
that path exactly | the renderer | Paper only |
velocity.toml |
that path exactly | the renderer | Velocity only |
lang |
the name and everything under it | Velocity itself | Velocity only |
plugins and lang are refused whether the claim holds a directory or a plain
file of that name.
The list follows the flavour, and that is not an oversight. A Paper server
does not refuse velocity.toml or lang/: nothing on a Paper server writes
either, so refusing them would be a rule with no reason behind it — and would
crash-loop a group whose claim carries a lang directory for something else
entirely. A proxy likewise does not refuse the Paper files.
lang/ is the entry worth explaining. Velocity migrates
lang/messages.properties to MiniMessage on every start and writes the result
back, so a file placed there is overwritten before anybody reads it. Nothing
breaks — which is exactly why it is refused. A copy that silently does nothing
is worse than a collision that announces itself.
The remedy is never "put it somewhere else in the claim". For the renderer's
files it is spec.configOverlay; for plugins/ it is extraPlugins.
The refusal arrives at start, not at admission. A claim's contents are not
knowable when somebody writes the group, so a wrong file crash-loops the pod
rather than failing the kubectl apply. What makes that liveable is that the
message says what is wrong in one sentence, in the container log an operator
already reads when a group does not come up:
spawnery: spec.extraFiles carries server.properties, which the operator writes itself.
spawnery: use spec.configOverlay for it. Refusing to start.
Styling what the agent says¶
Network.spec.defaults.feedFormat sets the shape of every line the agent
writes into chat — both an announcement about the cloud and a reply to a
/cloud command. One field rather than two, because they come from the same
plugin and a network that styles one should not have to style the other to
match.
It is MiniMessage, which
both Paper and Velocity parse. $EVENT_MESSAGE is replaced by what the line
has to say; everything around it is yours. The default:
spec:
defaults:
feedFormat: "<gray>»</gray> <gradient:aqua:green>Spawnery</gradient> <dark_gray>|</dark_gray> <gray>$EVENT_MESSAGE"
Changing it rolls nothing. The format travels in the network picture the operator already sends, not in the pod — a pod's environment is part of the pod hash, so a format carried there would make re-wording a chat line replace every server on the network. An edit takes effect within a resync interval.
A blank value falls back to that default rather than printing nothing, which is also what an agent does when talking to an operator too old to send the field.
Colour is used where it carries meaning, and the format cannot change that
part. Inside $EVENT_MESSAGE a server that takes joins is green and one that
does not is red — that is the question somebody is actually asking, and it
disagrees with the phase during a drain. Warnings are red for the same reason.