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