also looking at this
fix(deploy): allocate site ports per box, so a second attach cannot collide
#170
4 files
+643
-1
| @@ -384,6 +384,56 @@ regenerates the route config and restarts the gateway — so new | ||
| 384 | 384 | server-app/server-static sites appear automatically. Leaving `proxy` unset |
| 385 | 385 | keeps the prior behavior (no gateway installed; you run your own). |
| 386 | 386 | |
| 387 | ### Ports on a shared box | |
| 388 | ||
| 389 | Each 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 | |
| 391 | what lets several independent projects share one box: routes are keyed by host, so | |
| 392 | they compose. | |
| 393 | ||
| 394 | Ports do not compose. Every `server-app` route resolves to `from: | |
| 395 | 'localhost:<port>'` on one shared loopback namespace, and apps generated from the | |
| 396 | same template declare the same ports. `validateDeploymentConfig` only ever sees | |
| 397 | one project's `sites`, so a second project attaching to an occupied box passes | |
| 398 | validation. | |
| 399 | ||
| 400 | What happens next is worse than a failure. ts-cloud's units do not set exclusive | |
| 401 | binding, so both listeners bind and the kernel load-balances between them: nothing | |
| 402 | errors, both services look healthy, and each domain answers with the other | |
| 403 | project'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 | |
| 405 | and stops the deploy, but its only remedy is to tell you to pick free ports by | |
| 406 | hand. | |
| 407 | ||
| 408 | The fragments already answer this, since every one of them records its upstream | |
| 409 | port. `site-ports.ts` reads them: | |
| 410 | ||
| 411 | ```typescript | |
| 412 | import { allocateSitePorts, buildHostSitePortsScript, occupiedHostPorts, parseHostSiteFragments } from './deploy/site-ports' | |
| 413 | import { validateDeploymentConfig } from './deploy/site-target' | |
| 414 | ||
| 415 | // `stdout` is the output of buildHostSitePortsScript() run on the box. | |
| 416 | const occupiedPorts = occupiedHostPorts(parseHostSiteFragments(stdout), { ignoreSlug: config.project.slug }) | |
| 417 | ||
| 418 | // Reports a collision at plan time, naming the project that holds the port. | |
| 419 | const { errors } = validateDeploymentConfig(config, { occupiedPorts }) | |
| 420 | ||
| 421 | // Or allocate around it, so no app has to hand-pick a globally unique port. | |
| 422 | const { allocations } = allocateSitePorts(config, occupiedPorts) | |
| 423 | ``` | |
| 424 | ||
| 425 | Two 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 | ||
| 434 | Omitting `occupiedPorts` validates exactly as before, so this is additive for any | |
| 435 | single-project box. | |
| 436 | ||
| 387 | 437 | ## Preset Configuration |
| 388 | 438 | |
| 389 | 439 | ### Static Site Preset |
| @@ -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 | } | |
| @@ -125,6 +125,30 @@ export interface DeploymentValidationResult { | ||
| 125 | 125 | warnings: string[] |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | export 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 | ||
| 128 | 152 | /** |
| 129 | 153 | * Validate the per-site deployment configuration up front, turning what used to |
| 130 | 154 | * be silent runtime failures (e.g. a `start` site with no compute server) into |
| @@ -133,7 +157,10 @@ export interface DeploymentValidationResult { | ||
| 133 | 157 | * Never throws — returns structured `{ errors, warnings }`. Callers should abort |
| 134 | 158 | * on any error and print warnings while continuing. |
| 135 | 159 | */ |
| 136 | export function validateDeploymentConfig(config: CloudConfig): DeploymentValidationResult { | |
| 160 | export function validateDeploymentConfig( | |
| 161 | config: CloudConfig, | |
| 162 | options: ValidateDeploymentOptions = {}, | |
| 163 | ): DeploymentValidationResult { | |
| 137 | 164 | const errors: string[] = [] |
| 138 | 165 | const warnings: string[] = [] |
| 139 | 166 | const sites = config.sites || {} |
| @@ -233,6 +260,18 @@ export function validateDeploymentConfig(config: CloudConfig): DeploymentValidat | ||
| 233 | 260 | } |
| 234 | 261 | |
| 235 | 262 | 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 | ||
| 236 | 275 | const existing = portOwners.get(site.port) |
| 237 | 276 | if (existing) { |
| 238 | 277 | errors.push( |
| @@ -0,0 +1,294 @@ | ||
| 1 | import type { CloudConfig, SiteConfig } from '@ts-cloud/core' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { RPX_SITES_DIR } from '../../src/drivers/shared/rpx-gateway' | |
| 4 | import { | |
| 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' | |
| 13 | import { validateDeploymentConfig } from '../../src/deploy/site-target' | |
| 14 | ||
| 15 | function 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. */ | |
| 25 | function encodeFragments(...fragments: unknown[]): string { | |
| 26 | return `${fragments.map(f => Buffer.from(JSON.stringify(f, null, 2)).toString('base64')).join('\n')}\n` | |
| 27 | } | |
| 28 | ||
| 29 | describe('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 | ||
| 37 | describe('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 | ||
| 56 | describe('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 | ||
| 109 | describe('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 | ||
| 123 | describe('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 | ||
| 154 | describe('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 | ||
| 228 | describe('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 | }) | |