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
+109 -2
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { db } from '../db';
import { pluginInstalls } from './schema';
import { pluginInstalls, pluginPorts } from './schema';
export type PluginInstall = typeof pluginInstalls.$inferSelect;
@@ -47,3 +47,110 @@ export async function removePluginInstall(appName: string): Promise<boolean> {
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
return rows.length > 0;
}
// ── Ports the platform hands out and remembers ────────────────────────────────────────────────────
export type PluginPort = typeof pluginPorts.$inferSelect;
/** Every allocation, for the settings screen and for anyone asking what is spoken for. */
export async function listPluginPorts(): Promise<PluginPort[]> {
return db.select().from(pluginPorts).orderBy(pluginPorts.port);
}
/** What this plugin has been given, as `{ name: port }`. Empty when it has asked for nothing. */
export async function getPluginPorts(appName: string): Promise<Record<string, number>> {
const rows = await db.select().from(pluginPorts).where(eq(pluginPorts.appName, appName));
return Object.fromEntries(rows.map((r) => [r.name, r.port]));
}
/**
* Find a port nothing is using, by asking the kernel and then checking our own book.
*
* Binding `port: 0` is the only honest way to know a port is free — a list of what we have handed out
* says nothing about what else on this machine is listening, and a hardcoded range says nothing about
* either. So the kernel picks, we let go, and the caller binds it a moment later.
*
* ── The gap between letting go and binding ──
*
* Real, and unavoidable without holding the socket open across a process boundary. Something else can
* take the port in between. That is why allocation is not the last word: a sidecar that cannot bind its
* remembered port must say so loudly rather than quietly choosing another, because the number is written
* down in configuration this database cannot see.
*/
/** Can we bind it right now? The only honest test, and true only for this instant. */
async function isFree(port: number): Promise<boolean> {
try {
const probe = Bun.serve({ port, hostname: '127.0.0.1', fetch: () => new Response('') });
probe.stop(true);
return true;
} catch {
return false;
}
}
async function freePort(taken: Set<number>): Promise<number> {
for (let attempt = 0; attempt < 20; attempt++) {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = probe.port;
probe.stop(true);
if (port && !taken.has(port)) return port;
}
throw new Error('could not find a free port after 20 attempts');
}
/**
* The port this plugin was given for `name`, allocating one the first time.
*
* Idempotent and stable: the same plugin and name always gets the same number back, which is the entire
* point — see the note on `pluginPorts`. Re-installing, upgrading and restarting all return what was
* given the first time.
*/
export async function allocatePluginPort(appName: string, name: string, preferred?: number): Promise<number> {
const [existing] = await db
.select()
.from(pluginPorts)
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
if (existing) return existing.port;
/*
Racing another install is possible and is handled by the unique index rather than by a lock: two
installs running at once can be handed the same number by two different kernel probes, and exactly
one of the inserts will win. The loser tries again with a fresh number rather than failing, because
"somebody else installed a plugin at the same moment" is not a reason to refuse.
*/
for (let attempt = 0; attempt < 5; attempt++) {
const taken = new Set((await db.select().from(pluginPorts)).map((r) => r.port));
/*
The wish first, and only on the first attempt.
A preferred port that is already spoken for, or that something else on this machine is listening
on, is not an error — it is a plugin arriving somewhere its ideal address is taken, which is
ordinary. It falls back to any free one rather than failing, because an install that refuses over
a preference would make `preferred` a claim.
*/
const wish = attempt === 0 && preferred && !taken.has(preferred) && (await isFree(preferred)) ? preferred : null;
const port = wish ?? (await freePort(taken));
try {
const [row] = await db.insert(pluginPorts).values({ appName, name, port }).returning();
return row!.port;
} catch {
const [now] = await db
.select()
.from(pluginPorts)
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
if (now) return now.port; // the other writer was allocating the same thing
}
}
throw new Error(`could not allocate a port for ${appName}/${name}`);
}
/**
* Give up a plugin's ports. NOT called on uninstall — see the note in schema.ts.
*
* The number lives in configuration this database cannot see, so handing it to something else because a
* plugin was briefly removed points that configuration at the wrong service. Only `purge` does this.
*/
export async function releasePluginPorts(appName: string): Promise<number> {
const rows = await db.delete(pluginPorts).where(eq(pluginPorts.appName, appName)).returning();
return rows.length;
}
+55 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, text, integer, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
// Which plugins are installed on this machine, and whether they should be mounted.
//
@@ -56,3 +56,57 @@ export const pluginInstalls = pgTable(
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
],
);
/**
* Ports the platform has given out to plugins, and remembers.
*
* ── Why the platform allocates these when it allocates nothing else ──
*
* A sidecar's own port is not allocated: it binds `port: 0`, the kernel picks one, and it announces the
* number over the registration socket. That is right for what it is — a private detail between two
* processes, on loopback, re-chosen every restart, and nothing outside Officer ever needs to know it.
*
* A port something ELSE connects to is a different thing entirely. A plugin serving a site has to be
* reachable from nginx, which means the number must be stable across restarts and must be knowable by a
* human writing a proxy configuration. Ephemeral fails both.
*
* Doing it here rather than in each plugin's own settings buys the one thing per-plugin config cannot:
* **two plugins cannot be given the same number**, and the platform knows which are spoken for. That is
* `uq_plugin_ports_port` below, and it is the whole argument for this table.
*
* ── Kept when a plugin is uninstalled ──
*
* Deliberate. The number is written down somewhere this database cannot see — an nginx proxy host, a
* firewall rule, somebody's notes — so handing it to a different plugin because this one was briefly
* removed would point that configuration at the wrong service. Reinstalling gets the same port back.
* `purge` is the deliberate act that gives it up.
*/
export const pluginPorts = pgTable(
'plugin_ports',
{
id: serial('id').primaryKey(),
/** The plugin's app name — its directory, as everywhere else. */
appName: text('app_name').notNull(),
/**
* Which of the plugin's ports this is, as the manifest named it.
*
* A plugin may want more than one — a site and a preview of it — so the number alone is not an
* identity. Reaches the sidecar as `PORT_<NAME>` in its environment, uppercased.
*/
name: text('name').notNull(),
port: integer('port').notNull(),
allocatedAt: timestamp('allocated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One allocation per named port per plugin.
uniqueIndex('uq_plugin_ports_app_name_name').on(t.appName, t.name),
/*
No two plugins on the same number, said structurally.
This is the reason the table exists rather than a convention people follow. A collision here is not
a duplicate row, it is two services silently fighting over a listener, where the loser fails to bind
at startup and the winner is whichever happened to start first.
*/
uniqueIndex('uq_plugin_ports_port').on(t.port),
],
);