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
packages/ts-cloud/src/deploy/site-ports.tsadded+259-0
Changes to packages/ts-cloud/src/deploy/site-ports.ts
@@ -0,0 +1,259 @@
1import type { CloudConfig } from '@ts-cloud/core'
2import { resolveSiteKind } from './site-target'
3
4/**
5 * Port ownership across the projects sharing one box.
6 *
7 * A box can host several independent projects: each one's deploy writes its own
8 * rpx registry fragment and the assembler merges them (see `RPX_SITES_DIR`).
9 * That composes cleanly for ROUTES, because routes are keyed by host. It does
10 * not compose for PORTS, because every app generated from the same template
11 * declares the same loopback ports, and `validateDeploymentConfig` only ever
12 * sees one project's `sites`.
13 *
14 * The second attach therefore passes validation, and then does something worse
15 * than failing: ts-cloud's units do not set exclusive binding, so both listeners
16 * bind and the kernel load-balances between them. Nothing errors, both services
17 * look healthy, and each domain answers with the other project's site for about
18 * half its requests. That is not hypothetical - it is what happened to
19 * predicthq.org when a storefront picked a port by reading other projects'
20 * config files rather than the box (see `assertPortsAreFree` in stacks).
21 *
22 * `assertPortsAreFree` catches this late, from the deploying box over SSH, by
23 * comparing wanted ports against live listeners. That is the better evidence and
24 * it stays the last line of defence. What it cannot do is avoid the clash: it
25 * exits and tells the operator to go pick free ports by hand. This module is the
26 * other half - deciding the ports before anything is shipped, from data a plan
27 * already has.
28 *
29 * The fix does not need new bookkeeping on the box. The fragments already record
30 * every upstream a project serves, so the host is its own port registry - it was
31 * simply never read. This module turns those fragments into a port -> owner map,
32 * finds the collisions, and allocates around them.
33 *
34 * Everything here is pure. The one thing that must touch the box, reading the
35 * fragments, is split into a script builder and a parser so both halves are
36 * testable without a server.
37 *
38 * @see https://github.com/stacksjs/ts-cloud/issues/168
39 */
40
41/**
42 * Where the rpx gateway keeps one registry fragment per project.
43 *
44 * Deliberately duplicated from `RPX_SITES_DIR` rather than imported:
45 * `rpx-gateway.ts` imports `site-target.ts`, and this module imports it too, so
46 * importing the constant from there would close a cycle. `site-ports.test.ts`
47 * asserts the two stay equal, so the duplicate cannot drift silently.
48 */
49export const HOST_SITES_DIR = '/etc/rpx/sites.d'
50
51export interface SitePortRange {
52 /** Lowest port the allocator may hand out, inclusive. */
53 start: number
54 /** Highest port the allocator may hand out, inclusive. */
55 end: number
56}
57
58/**
59 * The window the allocator searches.
60 *
61 * Chosen to contain the template's own defaults (3022/3023) so that an app which
62 * has never thought about ports keeps the ports it already had whenever they are
63 * free - see {@link allocateSitePorts}, which only moves a site that actually
64 * collides.
65 */
66export const DEFAULT_SITE_PORT_RANGE: SitePortRange = { start: 3000, end: 3999 }
67
68/**
69 * One project's registry fragment, as written by the deploy:
70 * `JSON.stringify({ slug, ...RpxGatewayConfig })`.
71 *
72 * Only the fields this module reads are declared. A fragment written by an older
73 * ts-cloud may be missing `slug`, which the writer defaults to `'app'`.
74 */
75export interface HostSiteFragment {
76 slug?: string
77 proxies?: Array<{ from?: string | string[] }>
78}
79
80/** Port -> the slug of the project that already serves it on this box. */
81export type PortOwners = Map<number, string>
82
83/** The slug a fragment belongs to, matching the writer's `'app'` default. */
84function fragmentSlug(fragment: HostSiteFragment): string {
85 return fragment.slug?.trim() || 'app'
86}
87
88/**
89 * The port from an rpx upstream (`host:port`).
90 *
91 * Splits on the LAST colon so a bracketed IPv6 literal (`[::1]:3022`) parses as
92 * port 3022 rather than as part of the address. Returns `undefined` for anything
93 * that is not a valid TCP port, so a malformed fragment narrows the map instead
94 * of poisoning it with NaN.
95 */
96export function parseUpstreamPort(upstream: string): number | undefined {
97 const separator = upstream.lastIndexOf(':')
98 if (separator < 0 || separator === upstream.length - 1) return undefined
99
100 const candidate = upstream.slice(separator + 1).trim()
101 if (!/^\d+$/.test(candidate)) return undefined
102
103 const port = Number(candidate)
104 return port >= 1 && port <= 65535 ? port : undefined
105}
106
107/**
108 * Every port the box already serves, mapped to the project that owns it.
109 *
110 * `ignoreSlug` must be set to the deploying project's own slug. Its fragment is
111 * already on the box from the previous deploy, so without that the second deploy
112 * of an attached app reports a conflict with itself and can never succeed.
113 *
114 * First writer wins for a given port, which keeps the result deterministic when
115 * two fragments disagree - a state that is itself the bug being reported, so the
116 * caller sees one stable owner rather than an order-dependent one.
117 */
118export function occupiedHostPorts(
119 fragments: HostSiteFragment[],
120 options: { ignoreSlug?: string } = {},
121): PortOwners {
122 const ignore = options.ignoreSlug?.trim()
123 const owners: PortOwners = new Map()
124
125 for (const fragment of fragments) {
126 const slug = fragmentSlug(fragment)
127 if (ignore && slug === ignore) continue
128
129 for (const proxy of fragment.proxies ?? []) {
130 const upstreams = typeof proxy.from === 'string' ? [proxy.from] : (proxy.from ?? [])
131 for (const upstream of upstreams) {
132 const port = parseUpstreamPort(upstream)
133 if (port !== undefined && !owners.has(port)) owners.set(port, slug)
134 }
135 }
136 }
137
138 return owners
139}
140
141/**
142 * A shell snippet that dumps every registry fragment on the box, one
143 * base64-encoded JSON document per line.
144 *
145 * base64 rather than raw `cat`, because the fragments are pretty-printed and so
146 * span many lines; encoding makes the output unambiguously one record per line
147 * without depending on a JSON tool being installed. Only POSIX utilities plus
148 * `base64` are used, both present on the provisioned image - the same reason
149 * `resize-remote.ts` enumerates these files with plain `find`.
150 *
151 * A missing directory is not an error: a box with no fragments yet prints
152 * nothing, and {@link parseHostSiteFragments} reads that as "no co-tenants".
153 */
154export function buildHostSitePortsScript(sitesDir: string = HOST_SITES_DIR): string {
155 return [
156 `for __tsc_fragment in ${sitesDir}/*.json; do`,
157 ' [ -f "$__tsc_fragment" ] || continue',
158 ' base64 < "$__tsc_fragment" | tr -d \'\\n\'',
159 ' printf \'\\n\'',
160 'done',
161 ].join('\n')
162}
163
164/**
165 * Parse the output of {@link buildHostSitePortsScript}.
166 *
167 * A fragment that will not decode or parse is skipped rather than thrown, which
168 * matches how the box's own assembler treats a corrupt fragment: one bad file
169 * must not take the operation down. The cost is that its ports are invisible to
170 * the collision check, which is strictly better than today, where every
171 * project's ports are.
172 */
173export function parseHostSiteFragments(stdout: string): HostSiteFragment[] {
174 const fragments: HostSiteFragment[] = []
175
176 for (const line of stdout.split('\n')) {
177 const encoded = line.trim()
178 if (!encoded) continue
179
180 try {
181 const parsed = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'))
182 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) fragments.push(parsed as HostSiteFragment)
183 }
184 catch {
185 continue
186 }
187 }
188
189 return fragments
190}
191
192export interface SitePortAllocation {
193 /** Site name within the deploying project's `sites`. */
194 site: string
195 /** The port the site should bind after allocation. */
196 port: number
197 /** What the config asked for, when it asked for anything. */
198 declared?: number
199 /** Did the allocation have to move the site off its declared port? */
200 moved: boolean
201}
202
203export interface SitePortAllocationResult {
204 allocations: SitePortAllocation[]
205 errors: string[]
206}
207
208/**
209 * Assign a free port to every server-app site, preferring the one it declared.
210 *
211 * The declared port is kept whenever it is free, so allocation is a no-op on a
212 * box with no co-tenants and an app's ports stay stable across deploys. A site
213 * whose port is taken walks upward to the next free one, which keeps the result
214 * deterministic and close to what the author wrote - a template app landing on
215 * 3024 rather than an arbitrary high port.
216 *
217 * Only `server-app` sites are considered. A bucket, static, redirect or proxy
218 * site binds nothing, and `validateDeploymentConfig` already warns when one of
219 * those declares a port.
220 */
221export function allocateSitePorts(
222 config: CloudConfig,
223 occupied: ReadonlyMap<number, string>,
224 range: SitePortRange = DEFAULT_SITE_PORT_RANGE,
225): SitePortAllocationResult {
226 const allocations: SitePortAllocation[] = []
227 const errors: string[] = []
228 const taken = new Set<number>(occupied.keys())
229
230 for (const [name, site] of Object.entries(config.sites ?? {})) {
231 if (!site || resolveSiteKind(site) !== 'server-app') continue
232
233 const declared = typeof site.port === 'number' ? site.port : undefined
234 const preferred = Math.max(declared ?? range.start, range.start)
235
236 let port: number | undefined
237 for (let candidate = preferred; candidate <= range.end; candidate++) {
238 if (!taken.has(candidate)) {
239 port = candidate
240 break
241 }
242 }
243
244 if (port === undefined) {
245 const owner = declared !== undefined ? occupied.get(declared) : undefined
246 errors.push(
247 `No free port for site '${name}' in ${range.start}-${range.end}`
248 + `${declared !== undefined ? ` (wanted ${declared}${owner ? `, held by '${owner}'` : ''})` : ''}. `
249 + 'Widen the range or free a port on the box.',
250 )
251 continue
252 }
253
254 taken.add(port)
255 allocations.push({ site: name, port, declared, moved: declared !== undefined && port !== declared })
256 }
257
258 return { allocations, errors }
259}
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(
packages/ts-cloud/test/deploy/site-ports.test.tsadded+294-0
Changes to packages/ts-cloud/test/deploy/site-ports.test.ts
@@ -0,0 +1,294 @@
1import type { CloudConfig, SiteConfig } from '@ts-cloud/core'
2import { describe, expect, it } from 'bun:test'
3import { RPX_SITES_DIR } from '../../src/drivers/shared/rpx-gateway'
4import {
5 allocateSitePorts,
6 buildHostSitePortsScript,
7 DEFAULT_SITE_PORT_RANGE,
8 HOST_SITES_DIR,
9 occupiedHostPorts,
10 parseHostSiteFragments,
11 parseUpstreamPort,
12} from '../../src/deploy/site-ports'
13import { validateDeploymentConfig } from '../../src/deploy/site-target'
14
15function makeConfig(sites: Record<string, SiteConfig>, slug = 'loghq'): CloudConfig {
16 return {
17 project: { name: slug, slug, region: 'us-east-1' },
18 environments: { production: { type: 'production' } },
19 cloud: { provider: 'hetzner', attachTo: 'statushq' },
20 sites,
21 } as CloudConfig
22}
23
24/** Encode fragments the way `buildHostSitePortsScript` emits them. */
25function encodeFragments(...fragments: unknown[]): string {
26 return `${fragments.map(f => Buffer.from(JSON.stringify(f, null, 2)).toString('base64')).join('\n')}\n`
27}
28
29describe('HOST_SITES_DIR', () => {
30 // site-ports cannot import RPX_SITES_DIR without closing an import cycle, so
31 // the literal is duplicated. This is what stops the duplicate drifting.
32 it('matches the gateway registry directory it duplicates', () => {
33 expect(HOST_SITES_DIR).toBe(RPX_SITES_DIR)
34 })
35})
36
37describe('parseUpstreamPort', () => {
38 it('reads the port from a host:port upstream', () => {
39 expect(parseUpstreamPort('127.0.0.1:3022')).toBe(3022)
40 expect(parseUpstreamPort('localhost:80')).toBe(80)
41 })
42
43 it('splits on the last colon so bracketed IPv6 parses', () => {
44 expect(parseUpstreamPort('[::1]:3023')).toBe(3023)
45 })
46
47 it('rejects anything that is not a usable port', () => {
48 expect(parseUpstreamPort('127.0.0.1')).toBeUndefined()
49 expect(parseUpstreamPort('127.0.0.1:')).toBeUndefined()
50 expect(parseUpstreamPort('127.0.0.1:bun')).toBeUndefined()
51 expect(parseUpstreamPort('127.0.0.1:0')).toBeUndefined()
52 expect(parseUpstreamPort('127.0.0.1:70000')).toBeUndefined()
53 })
54})
55
56describe('occupiedHostPorts', () => {
57 it('maps every upstream port to the project serving it', () => {
58 const owners = occupiedHostPorts([
59 { slug: 'statushq', proxies: [{ from: '127.0.0.1:3000' }, { from: '127.0.0.1:3001' }] },
60 { slug: 'bughq', proxies: [{ from: '127.0.0.1:3022' }] },
61 ])
62
63 expect([...owners.entries()].sort((a, b) => a[0] - b[0])).toEqual([
64 [3000, 'statushq'],
65 [3001, 'statushq'],
66 [3022, 'bughq'],
67 ])
68 })
69
70 it('reads an array of upstreams, as a load-balanced route has', () => {
71 const owners = occupiedHostPorts([
72 { slug: 'statushq', proxies: [{ from: ['10.0.0.1:3100', '10.0.0.2:3101'] }] },
73 ])
74
75 expect(owners.get(3100)).toBe('statushq')
76 expect(owners.get(3101)).toBe('statushq')
77 })
78
79 it("attributes a fragment with no slug to 'app', matching the writer's default", () => {
80 expect(occupiedHostPorts([{ proxies: [{ from: '127.0.0.1:3022' }] }]).get(3022)).toBe('app')
81 })
82
83 it('skips the deploying project so a redeploy does not conflict with itself', () => {
84 const fragments = [
85 { slug: 'loghq', proxies: [{ from: '127.0.0.1:3022' }] },
86 { slug: 'bughq', proxies: [{ from: '127.0.0.1:3030' }] },
87 ]
88
89 const owners = occupiedHostPorts(fragments, { ignoreSlug: 'loghq' })
90
91 expect(owners.has(3022)).toBe(false)
92 expect(owners.get(3030)).toBe('bughq')
93 })
94
95 it('ignores routes with no upstream, such as static and redirect sites', () => {
96 expect(occupiedHostPorts([{ slug: 'statushq', proxies: [{}, { from: undefined }] }]).size).toBe(0)
97 })
98
99 it('keeps the first owner when two fragments claim one port', () => {
100 const owners = occupiedHostPorts([
101 { slug: 'bughq', proxies: [{ from: '127.0.0.1:3022' }] },
102 { slug: 'loghq', proxies: [{ from: '127.0.0.1:3022' }] },
103 ])
104
105 expect(owners.get(3022)).toBe('bughq')
106 })
107})
108
109describe('buildHostSitePortsScript', () => {
110 it('enumerates the registry directory and guards against the empty glob', () => {
111 const script = buildHostSitePortsScript()
112
113 expect(script).toContain(`${HOST_SITES_DIR}/*.json`)
114 expect(script).toContain('[ -f "$__tsc_fragment" ] || continue')
115 expect(script).toContain('base64')
116 })
117
118 it('accepts a custom directory', () => {
119 expect(buildHostSitePortsScript('/tmp/sites.d')).toContain('/tmp/sites.d/*.json')
120 })
121})
122
123describe('parseHostSiteFragments', () => {
124 it('round-trips what the script emits', () => {
125 const stdout = encodeFragments(
126 { slug: 'statushq', proxies: [{ to: 'status.example', from: '127.0.0.1:3000' }] },
127 { slug: 'bughq', proxies: [{ to: 'bugs.example', from: '127.0.0.1:3022' }] },
128 )
129
130 expect(parseHostSiteFragments(stdout).map(f => f.slug)).toEqual(['statushq', 'bughq'])
131 })
132
133 it('reads nothing from a box with no fragments', () => {
134 expect(parseHostSiteFragments('')).toEqual([])
135 expect(parseHostSiteFragments('\n \n')).toEqual([])
136 })
137
138 it('skips a corrupt fragment instead of failing the whole read', () => {
139 const good = Buffer.from(JSON.stringify({ slug: 'bughq', proxies: [{ from: '127.0.0.1:3022' }] })).toString('base64')
140 const notBase64Json = Buffer.from('this is not json').toString('base64')
141 const stdout = `${notBase64Json}\n${good}\n`
142
143 const fragments = parseHostSiteFragments(stdout)
144
145 expect(fragments).toHaveLength(1)
146 expect(fragments[0]!.slug).toBe('bughq')
147 })
148
149 it('ignores a fragment that is not an object', () => {
150 expect(parseHostSiteFragments(encodeFragments([1, 2], 'nope', 7))).toEqual([])
151 })
152})
153
154describe('allocateSitePorts', () => {
155 const sites: Record<string, SiteConfig> = {
156 app: { root: 'dist', start: 'bun run server.ts', port: 3022 },
157 api: { root: 'dist', start: 'bun run api.ts', port: 3023 },
158 }
159
160 it('keeps declared ports when the box has no co-tenants', () => {
161 const { allocations, errors } = allocateSitePorts(makeConfig(sites), new Map())
162
163 expect(errors).toEqual([])
164 expect(allocations).toEqual([
165 { site: 'app', port: 3022, declared: 3022, moved: false },
166 { site: 'api', port: 3023, declared: 3023, moved: false },
167 ])
168 })
169
170 it('moves only the sites whose ports are taken, to the next free port', () => {
171 // bughq is already on the box holding the template's default pair.
172 const occupied = new Map([[3022, 'bughq'], [3023, 'bughq']])
173
174 const { allocations, errors } = allocateSitePorts(makeConfig(sites), occupied)
175
176 expect(errors).toEqual([])
177 expect(allocations).toEqual([
178 { site: 'app', port: 3024, declared: 3022, moved: true },
179 { site: 'api', port: 3025, declared: 3023, moved: true },
180 ])
181 })
182
183 it('does not hand the same port to two sites in one config', () => {
184 const config = makeConfig({
185 app: { root: 'dist', start: 'bun run a.ts', port: 3022 },
186 api: { root: 'dist', start: 'bun run b.ts', port: 3022 },
187 })
188
189 const ports = allocateSitePorts(config, new Map()).allocations.map(a => a.port)
190
191 expect(new Set(ports).size).toBe(2)
192 expect(ports).toEqual([3022, 3023])
193 })
194
195 it('allocates from the range start for a site that declares no port', () => {
196 const config = makeConfig({ app: { root: 'dist', start: 'bun run server.ts' } })
197
198 expect(allocateSitePorts(config, new Map()).allocations).toEqual([
199 { site: 'app', port: DEFAULT_SITE_PORT_RANGE.start, declared: undefined, moved: false },
200 ])
201 })
202
203 it('ignores sites that bind nothing', () => {
204 const config = makeConfig({
205 bucket: { root: 'dist' },
206 static: { root: 'dist', deploy: 'server' },
207 redirect: { domain: 'old.example', redirect: 'new.example' },
208 proxy: { domain: 'svc.example', proxyTo: '127.0.0.1:9000' },
209 app: { root: 'dist', start: 'bun run server.ts', port: 3022 },
210 })
211
212 expect(allocateSitePorts(config, new Map()).allocations.map(a => a.site)).toEqual(['app'])
213 })
214
215 it('reports the holder when the range is exhausted', () => {
216 const occupied = new Map([[3022, 'bughq']])
217 const config = makeConfig({ app: { root: 'dist', start: 'bun run server.ts', port: 3022 } })
218
219 const { allocations, errors } = allocateSitePorts(config, occupied, { start: 3022, end: 3022 })
220
221 expect(allocations).toEqual([])
222 expect(errors).toHaveLength(1)
223 expect(errors[0]).toContain("site 'app'")
224 expect(errors[0]).toContain("held by 'bughq'")
225 })
226})
227
228describe('validateDeploymentConfig with co-tenant ports (#168)', () => {
229 const sites: Record<string, SiteConfig> = {
230 app: { root: 'dist', start: 'bun run server.ts', port: 3022 },
231 }
232
233 it('is silent about co-tenants when no occupancy is supplied', () => {
234 // The pre-existing contract: one config in, no knowledge of the box.
235 expect(validateDeploymentConfig(makeConfig(sites)).errors).toEqual([])
236 })
237
238 it('reports the collision at plan time, naming the project that holds the port', () => {
239 const errors = validateDeploymentConfig(makeConfig(sites), {
240 occupiedPorts: new Map([[3022, 'bughq']]),
241 }).errors
242
243 expect(errors).toHaveLength(1)
244 expect(errors[0]).toContain("Site 'app' wants port 3022")
245 expect(errors[0]).toContain("project 'bughq' already serves")
246 })
247
248 it('still reports two sites in one config sharing a port', () => {
249 const config = makeConfig({
250 app: { root: 'dist', start: 'bun run a.ts', port: 3022 },
251 api: { root: 'dist', start: 'bun run b.ts', port: 3022 },
252 })
253
254 const errors = validateDeploymentConfig(config).errors
255
256 expect(errors).toHaveLength(1)
257 expect(errors[0]).toContain('both use port 3022')
258 })
259
260 it("does not flag the deploying project's own fragment from a previous deploy", () => {
261 const stdout = encodeFragments({ slug: 'loghq', proxies: [{ from: '127.0.0.1:3022' }] })
262 const occupiedPorts = occupiedHostPorts(parseHostSiteFragments(stdout), { ignoreSlug: 'loghq' })
263
264 expect(validateDeploymentConfig(makeConfig(sites), { occupiedPorts }).errors).toEqual([])
265 })
266
267 it('catches the exact case from the issue: two template apps on one box', () => {
268 // loghq and bughq are both untouched from the template, so both want
269 // 3022/3023. bughq attached first and its fragment is on the box.
270 const onBox = encodeFragments({
271 slug: 'bughq',
272 proxies: [
273 { to: 'bugs.example', from: '127.0.0.1:3022' },
274 { to: 'bugs.example', path: '/api', from: '127.0.0.1:3023' },
275 ],
276 })
277
278 const loghq = makeConfig({
279 app: { root: 'dist', start: 'bun run server.ts', port: 3022 },
280 api: { root: 'dist', start: 'bun run api.ts', port: 3023 },
281 }, 'loghq')
282
283 const occupiedPorts = occupiedHostPorts(parseHostSiteFragments(onBox), { ignoreSlug: 'loghq' })
284 const { errors } = validateDeploymentConfig(loghq, { occupiedPorts })
285
286 expect(errors).toHaveLength(2)
287 expect(errors.join('\n')).toContain("project 'bughq'")
288
289 // ...and allocation gets loghq onto the box without either app editing a port.
290 const { allocations, errors: allocErrors } = allocateSitePorts(loghq, occupiedPorts)
291 expect(allocErrors).toEqual([])
292 expect(allocations.map(a => a.port)).toEqual([3024, 3025])
293 })
294})