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
+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}`);