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
+44 -11
View File
@@ -30,8 +30,22 @@ import type { DiscoveredPlugin } from './manifest';
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
export type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
/**
* One PM2 app entry. Matches what `services.sh` emits for the core processes.
*
* `env` carries the ports the platform allocated for this plugin — `PORT_<NAME>`. It is written into the
* ecosystem file rather than sent over 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.
*/
export type Pm2App = {
name: string;
script: string;
args?: string;
cwd: string;
watch: boolean;
env?: Record<string, string>;
};
/**
* The header to write when there is no file to take one from.
@@ -81,12 +95,24 @@ function writeEcosystemApps(apps: Pm2App[]): void {
const marker = existing.indexOf('module.exports');
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
const lines = apps.map(
(app) =>
const lines = apps.map((app) => {
/*
Values are emitted as JSON rather than quoted by hand. A port is a number today, but this is the
seam where a path or a URL arrives eventually, and one apostrophe in a value would produce a file
PM2 cannot parse — which takes every process on the machine down, not just this plugin's.
*/
const env =
app.env && Object.keys(app.env).length
? `, env: { ${Object.entries(app.env)
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(', ')} }`
: '';
return (
` { name: '${app.name}', script: '${app.script}'` +
(app.args ? `, args: '${app.args}'` : '') +
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
);
`, cwd: '${app.cwd}', watch: ${app.watch}${env} },`
);
});
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
}
@@ -98,7 +124,7 @@ function writeEcosystemApps(apps: Pm2App[]): void {
* the database says is installed and enabled. Neither should duplicate the other's idea of what a
* plugin's entry looks like.
*/
export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
export function pluginPm2Entry(plugin: DiscoveredPlugin, ports?: Record<string, number>): Pm2App | null {
if (!plugin.sidecar) return null;
const name = pluginProcessName(plugin.appName);
@@ -108,9 +134,16 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
: plugin.sidecar.script;
// `PORT_<NAME>`, uppercased. Absent entirely when a plugin asked for none, which is nearly all of them.
const env = Object.fromEntries(
Object.entries(ports ?? {}).map(([name, port]) => [`PORT_${name.toUpperCase().replace(/-/g, '_')}`, String(port)]),
);
const base = { name, cwd: PLATFORM_DIR, watch: false, ...(Object.keys(env).length ? { env } : {}) };
return plugin.sidecar.runtime === 'node'
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
? { ...base, script: 'node', args: script }
: { ...base, script: 'bun', args: `run ${script}` };
}
/**
@@ -119,8 +152,8 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
*/
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
const entry = pluginPm2Entry(plugin);
export function addPluginToEcosystem(plugin: DiscoveredPlugin, ports?: Record<string, number>): boolean {
const entry = pluginPm2Entry(plugin, ports);
if (!entry) return false;
const apps = readEcosystemApps().filter((a) => a.name !== entry.name);
+35 -2
View File
@@ -15,7 +15,7 @@ import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { allocatePluginPort, releasePluginPorts, revokeGrantsForPermissions } from 'officerdb';
import { grantPluginToAllRoles } from './grant-on-install';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { refreshPluginMounts, snapshotPlugins } from './mount';
@@ -453,7 +453,29 @@ export async function installPlugin(
);
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
/*
Ports the manifest asked the platform to own, allocated before the entry is written because the
entry carries them.
Idempotent: a plugin that already has an allocation gets the same number back, so re-installing
and upgrading do not move a port somebody has written into an nginx proxy host. See
`pluginPorts` in officerdb for why the platform allocates these when it allocates nothing else.
*/
const ports: Record<string, number> = {};
for (const declared of plugin.manifest.extraPorts ?? []) {
ports[declared.name] = await allocatePluginPort(appName, declared.name, declared.preferred);
}
if (Object.keys(ports).length) {
await step(
steps,
onStep,
`ports: ${Object.entries(ports)
.map(([name, port]) => `${name}=${port}`)
.join(', ')}`,
);
}
addPluginToEcosystem(plugin, ports);
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
@@ -609,6 +631,17 @@ export async function purgePlugin(
}
}
/*
Ports go here and NOT on uninstall, which is the deliberate asymmetry.
An allocated number is written down where this database cannot see it — an nginx proxy host, a
firewall rule, somebody's notes. Uninstalling is reversible and often temporary, so giving the number
up there would let a later plugin take it and point that configuration at the wrong service. Purge is
the act that says the plugin is gone for good, and that is when the number goes back in the pool.
*/
const released = await releasePluginPorts(appName);
if (released) await step(steps, onStep, `ports released: ${released}`);
await rm(plugin.dir, { recursive: true, force: true });
await step(steps, onStep, `deleted ${plugin.dir}`);
+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');