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/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})