ReviewOS

also looking at this

stacks/ts-cloud

fix(deploy): allocate site ports per box, so a second attach cannot collide

#170
Merged glennmichael123 wants to merge fix/attach-port-allocation into main
4 files +643 -1
docs/config.mdmodified+50-0
Changes to docs/config.md
@@ -384,6 +384,56 @@ regenerates the route config and restarts the gateway — so new
384384server-app/server-static sites appear automatically. Leaving `proxy` unset
385385keeps the prior behavior (no gateway installed; you run your own).
386386
387### Ports on a shared box
388
389Each project's deploy writes only its own route fragment,
390`/etc/rpx/sites.d/<slug>.json`, and the gateway merges them at startup. That is
391what lets several independent projects share one box: routes are keyed by host, so
392they compose.
393
394Ports do not compose. Every `server-app` route resolves to `from:
395'localhost:<port>'` on one shared loopback namespace, and apps generated from the
396same template declare the same ports. `validateDeploymentConfig` only ever sees
397one project's `sites`, so a second project attaching to an occupied box passes
398validation.
399
400What happens next is worse than a failure. ts-cloud's units do not set exclusive
401binding, so both listeners bind and the kernel load-balances between them: nothing
402errors, both services look healthy, and each domain answers with the other
403project's site for roughly half its requests. `buddy deploy` catches this late via
404`assertPortsAreFree`, which compares wanted ports against the box's live listeners
405and stops the deploy, but its only remedy is to tell you to pick free ports by
406hand.
407
408The fragments already answer this, since every one of them records its upstream
409port. `site-ports.ts` reads them:
410
411```typescript
412import { allocateSitePorts, buildHostSitePortsScript, occupiedHostPorts, parseHostSiteFragments } from './deploy/site-ports'
413import { validateDeploymentConfig } from './deploy/site-target'
414
415// `stdout` is the output of buildHostSitePortsScript() run on the box.
416const occupiedPorts = occupiedHostPorts(parseHostSiteFragments(stdout), { ignoreSlug: config.project.slug })
417
418// Reports a collision at plan time, naming the project that holds the port.
419const { errors } = validateDeploymentConfig(config, { occupiedPorts })
420
421// Or allocate around it, so no app has to hand-pick a globally unique port.
422const { allocations } = allocateSitePorts(config, occupiedPorts)
423```
424
425Two details matter:
426
427- **`ignoreSlug` is required.** This project's own fragment is already on the box
428 from its last deploy, so counting it makes every redeploy conflict with itself.
429- **A declared port is kept whenever it is free.** A box with no co-tenants
430 allocates nothing and ports stay stable across deploys; only a site that
431 actually collides moves, and it moves to the next free port rather than
432 somewhere arbitrary.
433
434Omitting `occupiedPorts` validates exactly as before, so this is additive for any
435single-project box.
436
387437## Preset Configuration
388438
389439### Static Site Preset