also looking at this
fix(deploy): allocate site ports per box, so a second attach cannot collide
#170
4 files
+643
-1
| @@ -0,0 +1,259 @@ | ||
| 1 | import type { CloudConfig } from '@ts-cloud/core' | |
| 2 | import { 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 | */ | |
| 49 | export const HOST_SITES_DIR = '/etc/rpx/sites.d' | |
| 50 | ||
| 51 | export 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 | */ | |
| 66 | export 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 | */ | |
| 75 | export 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. */ | |
| 81 | export type PortOwners = Map<number, string> | |
| 82 | ||
| 83 | /** The slug a fragment belongs to, matching the writer's `'app'` default. */ | |
| 84 | function 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 | */ | |
| 96 | export 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 | */ | |
| 118 | export 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 | */ | |
| 154 | export 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 | */ | |
| 173 | export 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 | ||
| 192 | export 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 | ||
| 203 | export 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 | */ | |
| 221 | export 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 | } | |