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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ts-cloud/src/deploy/site-ports.tsadded+245-0
Changes to packages/ts-cloud/src/deploy/site-ports.ts
@@ -0,0 +1,245 @@
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`. The second attach therefore passes validation and
13 * then fails at `systemctl start` with a bind error, naming neither culprit.
14 *
15 * The fix does not need new bookkeeping on the box. The fragments already record
16 * every upstream a project serves, so the host is its own port registry - it was
17 * simply never read. This module turns those fragments into a port -> owner map,
18 * finds the collisions, and allocates around them.
19 *
20 * Everything here is pure. The one thing that must touch the box, reading the
21 * fragments, is split into a script builder and a parser so both halves are
22 * testable without a server.
23 *
24 * @see https://github.com/stacksjs/ts-cloud/issues/168
25 */
26
27/**
28 * Where the rpx gateway keeps one registry fragment per project.
29 *
30 * Deliberately duplicated from `RPX_SITES_DIR` rather than imported:
31 * `rpx-gateway.ts` imports `site-target.ts`, and this module imports it too, so
32 * importing the constant from there would close a cycle. `site-ports.test.ts`
33 * asserts the two stay equal, so the duplicate cannot drift silently.
34 */
35export const HOST_SITES_DIR = '/etc/rpx/sites.d'
36
37export interface SitePortRange {
38 /** Lowest port the allocator may hand out, inclusive. */
39 start: number
40 /** Highest port the allocator may hand out, inclusive. */
41 end: number
42}
43
44/**
45 * The window the allocator searches.
46 *
47 * Chosen to contain the template's own defaults (3022/3023) so that an app which
48 * has never thought about ports keeps the ports it already had whenever they are
49 * free - see {@link allocateSitePorts}, which only moves a site that actually
50 * collides.
51 */
52export const DEFAULT_SITE_PORT_RANGE: SitePortRange = { start: 3000, end: 3999 }
53
54/**
55 * One project's registry fragment, as written by the deploy:
56 * `JSON.stringify({ slug, ...RpxGatewayConfig })`.
57 *
58 * Only the fields this module reads are declared. A fragment written by an older
59 * ts-cloud may be missing `slug`, which the writer defaults to `'app'`.
60 */
61export interface HostSiteFragment {
62 slug?: string
63 proxies?: Array<{ from?: string | string[] }>
64}
65
66/** Port -> the slug of the project that already serves it on this box. */
67export type PortOwners = Map<number, string>
68
69/** The slug a fragment belongs to, matching the writer's `'app'` default. */
70function fragmentSlug(fragment: HostSiteFragment): string {
71 return fragment.slug?.trim() || 'app'
72}
73
74/**
75 * The port from an rpx upstream (`host:port`).
76 *
77 * Splits on the LAST colon so a bracketed IPv6 literal (`[::1]:3022`) parses as
78 * port 3022 rather than as part of the address. Returns `undefined` for anything
79 * that is not a valid TCP port, so a malformed fragment narrows the map instead
80 * of poisoning it with NaN.
81 */
82export function parseUpstreamPort(upstream: string): number | undefined {
83 const separator = upstream.lastIndexOf(':')
84 if (separator < 0 || separator === upstream.length - 1) return undefined
85
86 const candidate = upstream.slice(separator + 1).trim()
87 if (!/^\d+$/.test(candidate)) return undefined
88
89 const port = Number(candidate)
90 return port >= 1 && port <= 65535 ? port : undefined
91}
92
93/**
94 * Every port the box already serves, mapped to the project that owns it.
95 *
96 * `ignoreSlug` must be set to the deploying project's own slug. Its fragment is
97 * already on the box from the previous deploy, so without that the second deploy
98 * of an attached app reports a conflict with itself and can never succeed.
99 *
100 * First writer wins for a given port, which keeps the result deterministic when
101 * two fragments disagree - a state that is itself the bug being reported, so the
102 * caller sees one stable owner rather than an order-dependent one.
103 */
104export function occupiedHostPorts(
105 fragments: HostSiteFragment[],
106 options: { ignoreSlug?: string } = {},
107): PortOwners {
108 const ignore = options.ignoreSlug?.trim()
109 const owners: PortOwners = new Map()
110
111 for (const fragment of fragments) {
112 const slug = fragmentSlug(fragment)
113 if (ignore && slug === ignore) continue
114
115 for (const proxy of fragment.proxies ?? []) {
116 const upstreams = typeof proxy.from === 'string' ? [proxy.from] : (proxy.from ?? [])
117 for (const upstream of upstreams) {
118 const port = parseUpstreamPort(upstream)
119 if (port !== undefined && !owners.has(port)) owners.set(port, slug)
120 }
121 }
122 }
123
124 return owners
125}
126
127/**
128 * A shell snippet that dumps every registry fragment on the box, one
129 * base64-encoded JSON document per line.
130 *
131 * base64 rather than raw `cat`, because the fragments are pretty-printed and so
132 * span many lines; encoding makes the output unambiguously one record per line
133 * without depending on a JSON tool being installed. Only POSIX utilities plus
134 * `base64` are used, both present on the provisioned image - the same reason
135 * `resize-remote.ts` enumerates these files with plain `find`.
136 *
137 * A missing directory is not an error: a box with no fragments yet prints
138 * nothing, and {@link parseHostSiteFragments} reads that as "no co-tenants".
139 */
140export function buildHostSitePortsScript(sitesDir: string = HOST_SITES_DIR): string {
141 return [
142 `for __tsc_fragment in ${sitesDir}/*.json; do`,
143 ' [ -f "$__tsc_fragment" ] || continue',
144 ' base64 < "$__tsc_fragment" | tr -d \'\\n\'',
145 ' printf \'\\n\'',
146 'done',
147 ].join('\n')
148}
149
150/**
151 * Parse the output of {@link buildHostSitePortsScript}.
152 *
153 * A fragment that will not decode or parse is skipped rather than thrown, which
154 * matches how the box's own assembler treats a corrupt fragment: one bad file
155 * must not take the operation down. The cost is that its ports are invisible to
156 * the collision check, which is strictly better than today, where every
157 * project's ports are.
158 */
159export function parseHostSiteFragments(stdout: string): HostSiteFragment[] {
160 const fragments: HostSiteFragment[] = []
161
162 for (const line of stdout.split('\n')) {
163 const encoded = line.trim()
164 if (!encoded) continue
165
166 try {
167 const parsed = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'))
168 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) fragments.push(parsed as HostSiteFragment)
169 }
170 catch {
171 continue
172 }
173 }
174
175 return fragments
176}
177
178export interface SitePortAllocation {
179 /** Site name within the deploying project's `sites`. */
180 site: string
181 /** The port the site should bind after allocation. */
182 port: number
183 /** What the config asked for, when it asked for anything. */
184 declared?: number
185 /** Did the allocation have to move the site off its declared port? */
186 moved: boolean
187}
188
189export interface SitePortAllocationResult {
190 allocations: SitePortAllocation[]
191 errors: string[]
192}
193
194/**
195 * Assign a free port to every server-app site, preferring the one it declared.
196 *
197 * The declared port is kept whenever it is free, so allocation is a no-op on a
198 * box with no co-tenants and an app's ports stay stable across deploys. A site
199 * whose port is taken walks upward to the next free one, which keeps the result
200 * deterministic and close to what the author wrote - a template app landing on
201 * 3024 rather than an arbitrary high port.
202 *
203 * Only `server-app` sites are considered. A bucket, static, redirect or proxy
204 * site binds nothing, and `validateDeploymentConfig` already warns when one of
205 * those declares a port.
206 */
207export function allocateSitePorts(
208 config: CloudConfig,
209 occupied: ReadonlyMap<number, string>,
210 range: SitePortRange = DEFAULT_SITE_PORT_RANGE,
211): SitePortAllocationResult {
212 const allocations: SitePortAllocation[] = []
213 const errors: string[] = []
214 const taken = new Set<number>(occupied.keys())
215
216 for (const [name, site] of Object.entries(config.sites ?? {})) {
217 if (!site || resolveSiteKind(site) !== 'server-app') continue
218
219 const declared = typeof site.port === 'number' ? site.port : undefined
220 const preferred = Math.max(declared ?? range.start, range.start)
221
222 let port: number | undefined
223 for (let candidate = preferred; candidate <= range.end; candidate++) {
224 if (!taken.has(candidate)) {
225 port = candidate
226 break
227 }
228 }
229
230 if (port === undefined) {
231 const owner = declared !== undefined ? occupied.get(declared) : undefined
232 errors.push(
233 `No free port for site '${name}' in ${range.start}-${range.end}`
234 + `${declared !== undefined ? ` (wanted ${declared}${owner ? `, held by '${owner}'` : ''})` : ''}. `
235 + 'Widen the range or free a port on the box.',
236 )
237 continue
238 }
239
240 taken.add(port)
241 allocations.push({ site: name, port, declared, moved: declared !== undefined && port !== declared })
242 }
243
244 return { allocations, errors }
245}
packages/ts-cloud/src/deploy/site-target.tsmodified+38-1
Changes to packages/ts-cloud/src/deploy/site-target.ts
@@ -125,6 +125,28 @@ 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, both pass, and the second one fail at
135 * `systemctl start` naming neither owner. Supplying this turns that into a
136 * plan-time error that names the project holding the port.
137 *
138 * Build it with `occupiedHostPorts()` from `./site-ports`, passing the
139 * deploying project's own slug as `ignoreSlug` - its fragment is already on the
140 * box from the last deploy, and counting it would make every redeploy conflict
141 * with itself.
142 *
143 * Omitted means "no co-tenant information", which validates exactly as before.
144 *
145 * @see https://github.com/stacksjs/ts-cloud/issues/168
146 */
147 occupiedPorts?: ReadonlyMap<number, string>
148}
149
128150/**
129151 * Validate the per-site deployment configuration up front, turning what used to
130152 * be silent runtime failures (e.g. a `start` site with no compute server) into
@@ -133,7 +155,10 @@ export interface DeploymentValidationResult {
133155 * Never throws — returns structured `{ errors, warnings }`. Callers should abort
134156 * on any error and print warnings while continuing.
135157 */
136export function validateDeploymentConfig(config: CloudConfig): DeploymentValidationResult {
158export function validateDeploymentConfig(
159 config: CloudConfig,
160 options: ValidateDeploymentOptions = {},
161): DeploymentValidationResult {
137162 const errors: string[] = []
138163 const warnings: string[] = []
139164 const sites = config.sites || {}
@@ -233,6 +258,18 @@ export function validateDeploymentConfig(config: CloudConfig): DeploymentValidat
233258 }
234259
235260 if (typeof site.port === 'number') {
261 // A co-tenant on the same box holds this port. Reported separately from
262 // the same-config clash below because the fix is different: the operator
263 // cannot see the other project's config from here, so the message has to
264 // name the owning project rather than a sibling site.
265 const coTenant = options.occupiedPorts?.get(site.port)
266 if (coTenant) {
267 errors.push(
268 `Site '${name}' wants port ${site.port}, which project '${coTenant}' already serves on this box. `
269 + `Attached projects share one port namespace. Give '${name}' a free port, or let the attach allocate one.`,
270 )
271 }
272
236273 const existing = portOwners.get(site.port)
237274 if (existing) {
238275 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})