Skip to content

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, without an image rebuild, a release, or a rolled fleet.

apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
  name: lobby
  namespace: minecraft
spec:
  # ...
  extraPlugins:
    claimName: minecraft-plugins

The same field exists on ProxyGroup.

Configuration outside plugins/ (Sponge's config/sponge/sponge.conf is the case that forced the question) belongs in extraFiles, the same mechanism one directory up.

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, which that flag's name never promised. 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. 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. It lets an operator make this installation runs no third-party plugins 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. It does not name 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, on purpose. 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. This is Longhorn's architecture and nothing in Spawnery can change it.

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.

The tree holds jars and their configuration together. 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, while 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. The operator holds a claim name, not a filesystem, so nothing about the volume's contents can reach the pod hash. That 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

It is not a plugin manager: nothing installs, updates, resolves dependencies for, or version-checks anything, and the tree is copied verbatim. It is not per-server, since the claim belongs to a group and every server in it gets the same tree. The copy runs one direction on every start. 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/. That is where a file that is not a plugin and that no mount can reach belongs, such as the config/sponge/sponge.conf Sponge reads.

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 its own switch, --allow-file-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, 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, because it would be overwritten on every start. spec.storage and a claim-backed spec.mounts entry 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. Instead of letting the copy order decide 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. 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.

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 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. To make that liveable, 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.

From an image instead of a claim

extraPlugins and extraFiles can name an image instead of a claim:

spec:
  extraPlugins:
    image: registry.example.net/lobby-plugins@sha256:…
  extraFiles:
    image: registry.example.net/lobby-files@sha256:…

The image's filesystem root is what the claim's root would be. It is mounted read-only as an image volume and copied exactly as a claim is. Every node pulls and caches it on its own, so no single server holds the group's plugins, and a new digest rolls the group like any other change. The pod's imagePullSecrets (from the Network) apply, and pullPolicy defaults to IfNotPresent. An image source needs neither --allow-plugin-volumes nor --allow-file-volumes.

Image volumes need Kubernetes 1.31 or later with the ImageVolume feature enabled (on by default only in recent releases) and a container runtime that supports them, such as containerd 2.1 or later. On a cluster without them the API server drops the volume's source and the pods of the group are refused, so check before switching a group: kubectl explain pod.spec.volumes.image answers on a cluster that has the field.

Placeholders filled at start

spec:
  substitution:
    prefix: SECRET_
  env:
    - name: SECRET_DB_PASSWORD
      valueFrom:
        secretKeyRef: { name: lobby-db, key: password }

After copying, the entrypoint replaces every {{ SECRET_… }} in the copied text files (.yml, .yaml, .json, .properties, .conf, .toml, .txt, .cfg) with the variable of that name, verbatim. A placeholder whose variable is missing stops the start and names the file and the placeholder. This keeps secrets out of the image or claim: the artifact carries the placeholder, the cluster the value.

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. It is one field because both 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 inside $EVENT_MESSAGE carries meaning, and the format cannot change it. A server that takes joins is green and one that does not is red, because that is the question somebody is actually asking, and it disagrees with the phase during a drain. Warnings are red for the same reason.