the platform can allocate a plugin a port, and remember it

A sidecar already gets a port and nothing here changes that: it binds 0, the
kernel picks, it announces the number. That is right for what it is - a private
detail between two processes, loopback, re-chosen every restart, and nothing
outside Officer needs it.

A port something ELSE connects to is a different thing. A plugin serving a
website has to be reachable from nginx, which means the number must survive a
restart and be knowable by whoever writes the proxy configuration. Ephemeral
fails both, which is why the first site plugin was going to hardcode 3011 and
hope.

So: `extraPorts: [{ name: 'site', preferred: 3011 }]` in a manifest, allocated at
install, persisted in plugin_ports, and delivered as PORT_SITE in the sidecar's
pm2 environment. Environment rather than the registration socket because a socket
answer arrives after the process has already had to decide what to bind, and pm2
is the only thing that can tell a process something before it starts.

What the platform guarantees is the part per-plugin config could never do: no two
plugins are ever handed the same number. That is a unique index rather than a
convention, because a collision is not a duplicate row - it is two services
fighting over a listener where the loser fails to bind and the winner is whoever
started first.

`preferred` is a wish, not a claim. A plugin usually arrives after the thing that
connects to it, and moving that configuration is work the platform can spare
somebody; but a preference that could fail an install would be a claim wearing a
softer word.

Allocations survive uninstall and are released only by purge. The number is
written down where this database cannot see it, so handing it to a different
plugin because this one was briefly removed points that configuration at the
wrong service.

Pm2App gained `env`, emitted as JSON rather than hand-quoted: a port is a number
today, but this is the seam where a path arrives eventually, and one apostrophe
in a value produces a file PM2 cannot parse - which takes every process on the
machine down, not just the plugin's.

Verified: stable across calls, distinct across plugins, released cleanly,
honours a preference, and a plugin declaring no ports gets no env key at all.
This commit is contained in:
2026-09-06 10:47:26 +01:00
parent b99a1e7443
commit 518d329c29
6 changed files with 333 additions and 18 deletions
+71
View File
@@ -405,6 +405,52 @@ export type PluginManifest = {
*/
protocolHeaders?: string[];
/**
* Ports this plugin needs the PLATFORM to allocate and remember.
*
* ── Not the same thing as a sidecar's own port ──
*
* A sidecar already gets a port, and nothing here changes that: it binds `port: 0`, the kernel picks
* one, and it announces the number over the registration socket. That port is a private detail between
* two processes — loopback, re-chosen on every restart, and nothing outside Officer ever needs it.
*
* This is for a port something ELSE connects to. A plugin serving a website has to be reachable from
* nginx, which means the number must survive a restart and must be knowable by whoever writes the proxy
* configuration. Ephemeral fails both, which is why the first site plugin had to hardcode 3011 and hope.
*
* ── What the platform guarantees, and what it does not ──
*
* Guaranteed: the same plugin and name always get the same number back, and no two plugins are ever
* given the same one — that second part is a unique index, not a convention, because a collision is two
* services fighting over a listener rather than a duplicate row.
*
* NOT guaranteed: that the port is still free when the sidecar starts. Something else on the machine
* can take it while Officer is down. A sidecar that cannot bind its remembered port must fail loudly
* rather than quietly choose another, because the number is written down where this platform cannot
* see it.
*
* ── How it reaches the sidecar ──
*
* As `PORT_<NAME>` in its environment, uppercased, written into the pm2 entry at install. The sidecar
* reads it rather than being told over the socket, because a socket answer would arrive after the
* process has already had to decide what to bind.
*
* ```ts
* extraPorts: [{ name: 'site' }] // any free port, remembered
* extraPorts: [{ name: 'site', preferred: 3011 }] // that one if it is free, otherwise any
* ```
*
* `preferred` is a WISH, not a claim. It exists because a plugin often arrives after the thing that
* connects to it — an nginx proxy host already pointing at 3011, a firewall rule already written — and
* moving that configuration is work the platform can spare somebody. If the number is already given to
* another plugin, or is not free on this machine, the plugin gets a different one and no error: a
* preference that could fail the install would be a claim wearing a softer word.
*
* Binding is still the sidecar's business, including which interface. A port that exists to be reached
* from a container cannot be loopback, and the platform has no way to know which a plugin means.
*/
extraPorts?: { name: string; preferred?: number }[];
/**
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
*
@@ -957,6 +1003,31 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
}
}
}
if (manifest.extraPorts !== undefined) {
if (!Array.isArray(manifest.extraPorts)) {
problems.push('extraPorts must be an array');
} else {
const seen = new Set<string>();
for (const [i, entry] of manifest.extraPorts.entries()) {
const name = (entry as { name?: unknown } | null)?.name;
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) {
problems.push(`extraPorts[${i}].name must be a lowercase name, e.g. "site"`);
continue;
}
// The name becomes an environment variable, so two that differ only by case would collide there
// while looking distinct here.
if (seen.has(name)) problems.push(`extraPorts[${i}] repeats the name "${name}"`);
seen.add(name);
const preferred = (entry as { preferred?: unknown }).preferred;
if (preferred !== undefined) {
// Above 1024 because nothing here runs as root, and below 65536 because that is the range.
const ok = typeof preferred === 'number' && Number.isInteger(preferred) && preferred > 1024 && preferred < 65536;
if (!ok) problems.push(`extraPorts[${i}].preferred must be a port number between 1025 and 65535`);
}
}
}
}
if (manifest.protocolHeaders !== undefined) {
if (!Array.isArray(manifest.protocolHeaders)) {
problems.push('protocolHeaders must be an array of header names');