also looking at this
fix(deploy): allocate site ports per box, so a second attach cannot collide
#170
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.
| @@ -0,0 +1,245 @@ | ||
| 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`. 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 | */ | |
| 35 | export const HOST_SITES_DIR = '/etc/rpx/sites.d' | |
| 36 | ||
| 37 | export 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 | */ | |
| 52 | export 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 | */ | |
| 61 | export 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. */ | |
| 67 | export type PortOwners = Map<number, string> | |
| 68 | ||
| 69 | /** The slug a fragment belongs to, matching the writer's `'app'` default. */ | |
| 70 | function 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 | */ | |
| 82 | export 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 | */ | |
| 104 | export 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 | */ | |
| 140 | export 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 | */ | |
| 159 | export 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 | ||
| 178 | export 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 | ||
| 189 | export 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 | */ | |
| 207 | export 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 | } | |
| @@ -125,6 +125,28 @@ 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, 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 | ||
| 128 | 150 | /** |
| 129 | 151 | * Validate the per-site deployment configuration up front, turning what used to |
| 130 | 152 | * be silent runtime failures (e.g. a `start` site with no compute server) into |
| @@ -133,7 +155,10 @@ export interface DeploymentValidationResult { | ||
| 133 | 155 | * Never throws — returns structured `{ errors, warnings }`. Callers should abort |
| 134 | 156 | * on any error and print warnings while continuing. |
| 135 | 157 | */ |
| 136 | export function validateDeploymentConfig(config: CloudConfig): DeploymentValidationResult { | |
| 158 | export function validateDeploymentConfig( | |
| 159 | config: CloudConfig, | |
| 160 | options: ValidateDeploymentOptions = {}, | |
| 161 | ): DeploymentValidationResult { | |
| 137 | 162 | const errors: string[] = [] |
| 138 | 163 | const warnings: string[] = [] |
| 139 | 164 | const sites = config.sites || {} |
| @@ -233,6 +258,18 @@ export function validateDeploymentConfig(config: CloudConfig): DeploymentValidat | ||
| 233 | 258 | } |
| 234 | 259 | |
| 235 | 260 | 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 | ||
| 236 | 273 | const existing = portOwners.get(site.port) |
| 237 | 274 | if (existing) { |
| 238 | 275 | 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 | }) | |