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