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
packages/ts-cloud/src/deploy/site-target.tsmodified+40-1
Changes to packages/ts-cloud/src/deploy/site-target.ts
@@ -125,6 +125,30 @@ export interface DeploymentValidationResult {
125125 warnings: string[]
126126}
127127
128export interface ValidateDeploymentOptions {
129 /**
130 * Ports this box already serves for OTHER projects, as port -> owning slug.
131 *
132 * Validation is otherwise blind to co-tenants: it only ever sees one project's
133 * `sites`, so two apps attached to the same server can both declare the
134 * template's default port and both pass. The box does not reject the second
135 * one either - ts-cloud's units do not set exclusive binding, so both bind and
136 * the kernel load-balances, leaving each domain serving the other project's
137 * site about half the time with nothing logged as an error. Supplying this
138 * turns that into a plan-time error naming the project holding the port.
139 *
140 * Build it with `occupiedHostPorts()` from `./site-ports`, passing the
141 * deploying project's own slug as `ignoreSlug` - its fragment is already on the
142 * box from the last deploy, and counting it would make every redeploy conflict
143 * with itself.
144 *
145 * Omitted means "no co-tenant information", which validates exactly as before.
146 *
147 * @see https://github.com/stacksjs/ts-cloud/issues/168
148 */
149 occupiedPorts?: ReadonlyMap<number, string>
150}
151
128152/**
129153 * Validate the per-site deployment configuration up front, turning what used to
130154 * be silent runtime failures (e.g. a `start` site with no compute server) into
@@ -133,7 +157,10 @@ export interface DeploymentValidationResult {
133157 * Never throws — returns structured `{ errors, warnings }`. Callers should abort
134158 * on any error and print warnings while continuing.
135159 */
136export function validateDeploymentConfig(config: CloudConfig): DeploymentValidationResult {
160export function validateDeploymentConfig(
161 config: CloudConfig,
162 options: ValidateDeploymentOptions = {},
163): DeploymentValidationResult {
137164 const errors: string[] = []
138165 const warnings: string[] = []
139166 const sites = config.sites || {}
@@ -233,6 +260,18 @@ export function validateDeploymentConfig(config: CloudConfig): DeploymentValidat
233260 }
234261
235262 if (typeof site.port === 'number') {
263 // A co-tenant on the same box holds this port. Reported separately from
264 // the same-config clash below because the fix is different: the operator
265 // cannot see the other project's config from here, so the message has to
266 // name the owning project rather than a sibling site.
267 const coTenant = options.occupiedPorts?.get(site.port)
268 if (coTenant) {
269 errors.push(
270 `Site '${name}' wants port ${site.port}, which project '${coTenant}' already serves on this box. `
271 + `Attached projects share one port namespace. Give '${name}' a free port, or let the attach allocate one.`,
272 )
273 }
274
236275 const existing = portOwners.get(site.port)
237276 if (existing) {
238277 errors.push(