also looking at this
feat(operations): fleet inventory, attach preflight, and the exports that were missing
#192
11 files
+1313
-5
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.
| @@ -5,8 +5,8 @@ const __dirname = import.meta.dirname | ||
| 5 | 5 | async function build() { |
| 6 | 6 | // Build the library root AND every subpath entry point declared in the |
| 7 | 7 | // package.json exports map ("./aws", "./deploy", "./dns", "./drivers", |
| 8 | // "./push", "./spend", "./protection"). Bundling only src/index.ts leaves | |
| 9 | // those subpaths as .d.ts-only | |
| 8 | // "./operations", "./push", "./spend", "./protection"). Bundling only | |
| 9 | // src/index.ts leaves those subpaths as .d.ts-only | |
| 10 | 10 | // in dist, so `import '@stacksjs/ts-cloud/drivers'` fails at runtime for |
| 11 | 11 | // consumers. Splitting keeps shared code in chunks instead of duplicating |
| 12 | 12 | // it into each subpath bundle. |
| @@ -17,6 +17,7 @@ async function build() { | ||
| 17 | 17 | join(__dirname, 'src/deploy/index.ts'), |
| 18 | 18 | join(__dirname, 'src/dns/index.ts'), |
| 19 | 19 | join(__dirname, 'src/drivers/index.ts'), |
| 20 | join(__dirname, 'src/operations/index.ts'), | |
| 20 | 21 | join(__dirname, 'src/push/index.ts'), |
| 21 | 22 | join(__dirname, 'src/spend/index.ts'), |
| 22 | 23 | join(__dirname, 'src/protection/index.ts'), |
| @@ -46,6 +46,10 @@ | ||
| 46 | 46 | "types": "./dist/drivers/index.d.ts", |
| 47 | 47 | "import": "./dist/drivers/index.js" |
| 48 | 48 | }, |
| 49 | "./operations": { | |
| 50 | "types": "./dist/operations/index.d.ts", | |
| 51 | "import": "./dist/operations/index.js" | |
| 52 | }, | |
| 49 | 53 | "./push": { |
| 50 | 54 | "types": "./dist/push/index.d.ts", |
| 51 | 55 | "import": "./dist/push/index.js" |
| @@ -4,6 +4,12 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | export * from './site-target' |
| 7 | // Co-tenancy on a shared box: which ports the host already serves and for whom | |
| 8 | // (`site-ports`), and the config editors that write the result back | |
| 9 | // (`site-config-editor`). Both shipped declarations with no reachable runtime | |
| 10 | // until this line - see https://github.com/stacksjs/ts-cloud/issues/191. | |
| 11 | export * from './site-config-editor' | |
| 12 | export * from './site-ports' | |
| 7 | 13 | export * from './server-dns' |
| 8 | 14 | export * from './dashboard-control-plane' |
| 9 | 15 | export * from './dashboard-route-manifest' |
| @@ -358,3 +358,66 @@ function findMatchingBrace(text: string, start: number): number { | ||
| 358 | 358 | |
| 359 | 359 | throw new Error('Could not find the closing brace for sites: { ... } in cloud.config.ts') |
| 360 | 360 | } |
| 361 | ||
| 362 | export interface SetAttachToInput { | |
| 363 | configText: string | |
| 364 | /** Slug of the project that owns the box this one is joining. */ | |
| 365 | owner: string | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Set `cloud.attachTo`, joining this project to a server another project owns. | |
| 370 | * | |
| 371 | * Deliberately narrow. This edits TypeScript source with text, which is only | |
| 372 | * defensible while it refuses everything it does not certainly understand, so | |
| 373 | * it handles exactly the shape the templates generate: | |
| 374 | * | |
| 375 | * cloud: { | |
| 376 | * provider: 'hetzner', | |
| 377 | * }, | |
| 378 | * | |
| 379 | * Anything else - two `cloud:` blocks, a nested object inside one, no block at | |
| 380 | * all - throws with what it saw, and the caller prints the edit for a person to | |
| 381 | * make. A config mangled by a clever regex is a far worse outcome than a config | |
| 382 | * the tool declined to touch. | |
| 383 | * | |
| 384 | * Idempotent: a config already attached to `owner` comes back byte for byte. | |
| 385 | * | |
| 386 | * @see https://github.com/stacksjs/ts-cloud/issues/167 | |
| 387 | */ | |
| 388 | export function setAttachToInCloudConfig(input: SetAttachToInput): string { | |
| 389 | const { configText, owner } = input | |
| 390 | const blocks = [...configText.matchAll(/\n( {2})cloud: \{\n([\s\S]*?)\n\1\},\n/g)] | |
| 391 | ||
| 392 | const [match] = blocks | |
| 393 | if (!match) throw new Error('No `cloud: { ... }` block found in the cloud config') | |
| 394 | if (blocks.length > 1) { | |
| 395 | throw new Error(`Found ${blocks.length} \`cloud: { ... }\` blocks in the cloud config, so which one to edit is ambiguous`) | |
| 396 | } | |
| 397 | ||
| 398 | const [whole, indent, body] = match | |
| 399 | if (indent === undefined || body === undefined) { | |
| 400 | throw new Error('The `cloud: { ... }` block did not parse into an indent and a body') | |
| 401 | } | |
| 402 | ||
| 403 | if (body.includes('{')) { | |
| 404 | throw new Error('The `cloud: { ... }` block holds a nested object, which this edit does not attempt to rewrite') | |
| 405 | } | |
| 406 | ||
| 407 | const existing = body.match(/^\s*attachTo:\s*(['"])([^'"]*)\1\s*,?\s*$/m) | |
| 408 | if (existing) { | |
| 409 | const [line, quote = '\'', current = ''] = existing | |
| 410 | if (current === owner) return configText | |
| 411 | ||
| 412 | return configText.replace(whole, whole.replace(line, line.replace(`${quote}${current}${quote}`, `'${owner}'`))) | |
| 413 | } | |
| 414 | ||
| 415 | const inner = `${indent} ` | |
| 416 | return configText.replace( | |
| 417 | whole, | |
| 418 | whole.replace( | |
| 419 | `\n${indent}},\n`, | |
| 420 | `\n${inner}// Deploy onto the box '${owner}' owns rather than provisioning one.\n${inner}attachTo: '${owner}',\n${indent}},\n`, | |
| 421 | ), | |
| 422 | ) | |
| 423 | } | |
| @@ -69,12 +69,28 @@ export const DEFAULT_SITE_PORT_RANGE: SitePortRange = { start: 3000, end: 3999 } | ||
| 69 | 69 | * One project's registry fragment, as written by the deploy: |
| 70 | 70 | * `JSON.stringify({ slug, ...RpxGatewayConfig })`. |
| 71 | 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'`. | |
| 72 | * Only the fields a reader here needs are declared. A fragment written by an | |
| 73 | * older ts-cloud may be missing `slug`, which the writer defaults to `'app'`. | |
| 74 | * | |
| 75 | * The route fields beyond `from` are declared for the fleet inventory | |
| 76 | * (`operations/inventory`), which answers "what is this box serving, for | |
| 77 | * whom" from these same files. They are optional and additive: this module's | |
| 78 | * port allocator still reads only `from`. | |
| 74 | 79 | */ |
| 75 | 80 | export interface HostSiteFragment { |
| 76 | 81 | slug?: string |
| 77 | proxies?: Array<{ from?: string | string[] }> | |
| 82 | proxies?: Array<{ | |
| 83 | /** Public host the route is served under. */ | |
| 84 | to?: string | |
| 85 | /** Path prefix within the host. Omitted means `/`. */ | |
| 86 | path?: string | |
| 87 | /** Upstream(s) for an app route. */ | |
| 88 | from?: string | string[] | |
| 89 | /** Served directory for a static route. */ | |
| 90 | static?: string | { dir?: string } | |
| 91 | /** Target for a redirect route. */ | |
| 92 | redirect?: string | { to?: string } | |
| 93 | }> | |
| 78 | 94 | } |
| 79 | 95 | |
| 80 | 96 | /** Port -> the slug of the project that already serves it on this box. */ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export * from './config' |
| 2 | export * from './operations' | |
| 2 | 3 | export * from './auth' |
| 3 | 4 | export * from './automation' |
| 4 | 5 | export * from './api' |
| @@ -260,7 +261,22 @@ export { | ||
| 260 | 261 | resolveSiteDeployTarget, |
| 261 | 262 | resolveSiteKind, |
| 262 | 263 | shipsARelease, |
| 264 | siteInstallBase, | |
| 263 | 265 | validateDeploymentConfig, |
| 266 | // Co-tenancy on a shared box: the ports the host already serves and for whom, | |
| 267 | // and the config editors that write an attach back. Reachable only since | |
| 268 | // https://github.com/stacksjs/ts-cloud/issues/191. | |
| 269 | addSiteToCloudConfig, | |
| 270 | allocateSitePorts, | |
| 271 | buildHostSitePortsScript, | |
| 272 | HOST_SITES_DIR, | |
| 273 | occupiedHostPorts, | |
| 274 | parseHostSiteFragments, | |
| 275 | parseUpstreamPort, | |
| 276 | removeSiteFromCloudConfig, | |
| 277 | setAttachToInCloudConfig, | |
| 278 | setSitePropertyInCloudConfig, | |
| 279 | updateSiteInCloudConfig, | |
| 264 | 280 | // Serverless application pipeline (Laravel-Vapor-equivalent) |
| 265 | 281 | buildAndPushServerlessImage, |
| 266 | 282 | buildFunctionEnv, |
| @@ -0,0 +1,25 @@ | ||
| 1 | /** | |
| 2 | * Fleet operations: plan-then-apply changes to live topology. | |
| 3 | * | |
| 4 | * Consolidating servers - moving an app to another box, attaching one as a | |
| 5 | * site, renaming a box, tearing a drained one down - is a routine cleanup that | |
| 6 | * is otherwise an afternoon of SSH. Every operation here is expressed as an | |
| 7 | * {@link OperationPlan}: it says what it would change before touching anything, | |
| 8 | * each step asks reality whether it is already satisfied so a run that died | |
| 9 | * halfway resumes by being run again, irreversible steps are gated on typed | |
| 10 | * confirmation, and nothing reads stdin so the whole sequence drives from CI. | |
| 11 | * | |
| 12 | * These modules existed for several releases without being exported, so they | |
| 13 | * type-checked on import and threw at runtime. This barrel is what makes them | |
| 14 | * reachable. | |
| 15 | * | |
| 16 | * @see https://github.com/stacksjs/ts-cloud/issues/167 | |
| 17 | * @see https://github.com/stacksjs/ts-cloud/issues/191 | |
| 18 | */ | |
| 19 | ||
| 20 | export * from './drained-sites' | |
| 21 | export * from './inventory' | |
| 22 | export * from './plan' | |
| 23 | export * from './server-rename' | |
| 24 | export * from './site-attach' | |
| 25 | export * from './site-move' | |
| @@ -0,0 +1,280 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { buildHostSitePortsScript, parseHostSiteFragments } from '../deploy/site-ports' | |
| 3 | import { | |
| 4 | formatInventory, | |
| 5 | probeHostRoutes, | |
| 6 | reconcile, | |
| 7 | routesFromFragments, | |
| 8 | tenantsOf, | |
| 9 | toInventoryServer, | |
| 10 | unaccountedSites, | |
| 11 | } from './inventory' | |
| 12 | ||
| 13 | /** | |
| 14 | * The trap this module exists to avoid: a project's own config describes ONE | |
| 15 | * project, the boxes are shared, and so reading config alone reports a | |
| 16 | * multi-tenant server as if that project were alone on it. Every test that | |
| 17 | * matters below is about a co-tenant being visible, or about a partial answer | |
| 18 | * being labelled partial rather than passed off as a complete one. | |
| 19 | */ | |
| 20 | ||
| 21 | const STACKS_FRAGMENT = { | |
| 22 | slug: 'stacks', | |
| 23 | proxies: [ | |
| 24 | { to: 'stacksjs.com', from: '127.0.0.1:3000' }, | |
| 25 | { to: 'stacksjs.com', path: '/docs', static: { dir: '/var/www/stacks-docs' } }, | |
| 26 | { to: 'stacksjs.com', path: '/discord', redirect: { to: 'https://discord.gg/example' } }, | |
| 27 | ], | |
| 28 | } | |
| 29 | ||
| 30 | const RAPPID_FRAGMENT = { | |
| 31 | slug: 'rappid', | |
| 32 | proxies: [{ to: 'rappid.hq.training', from: '127.0.0.1:3024' }], | |
| 33 | } | |
| 34 | ||
| 35 | function site(name: string, overrides: Record<string, any> = {}) { | |
| 36 | return { name, path: '/', loopbackOnly: overrides.domain === undefined, ...overrides } | |
| 37 | } | |
| 38 | ||
| 39 | describe('shaping a provider server', () => { | |
| 40 | it('resolves the ts-cloud identity a box was labelled with', () => { | |
| 41 | expect(toInventoryServer({ | |
| 42 | id: 12345, | |
| 43 | name: 'stacks-production-app', | |
| 44 | status: 'running', | |
| 45 | public_net: { ipv4: { ip: '5.161.0.1' } }, | |
| 46 | server_type: { name: 'cpx41' }, | |
| 47 | datacenter: { location: { name: 'fsn1' } }, | |
| 48 | labels: { 'ts-cloud/project': 'stacks', 'ts-cloud/environment': 'production', 'ts-cloud/role': 'app' }, | |
| 49 | })).toMatchObject({ | |
| 50 | id: '12345', | |
| 51 | name: 'stacks-production-app', | |
| 52 | ipv4: '5.161.0.1', | |
| 53 | type: 'cpx41', | |
| 54 | location: 'fsn1', | |
| 55 | project: 'stacks', | |
| 56 | environment: 'production', | |
| 57 | role: 'app', | |
| 58 | }) | |
| 59 | }) | |
| 60 | ||
| 61 | it('accepts a flatter record from a driver that is not Hetzner', () => { | |
| 62 | expect(toInventoryServer({ id: 2, name: 'box', status: 'running', ipv4: '1.2.3.4', type: 'medium', location: 'nbg1', labels: {} })) | |
| 63 | .toMatchObject({ ipv4: '1.2.3.4', type: 'medium', location: 'nbg1' }) | |
| 64 | }) | |
| 65 | ||
| 66 | it('keeps an unlabelled box rather than dropping it', () => { | |
| 67 | // A box provisioned by hand, or by a ts-cloud old enough not to label, is | |
| 68 | // exactly the kind a consolidation needs to see. | |
| 69 | expect(toInventoryServer({ id: 7, name: 'legacy-box', status: 'running', labels: {} })) | |
| 70 | .toMatchObject({ name: 'legacy-box', project: undefined }) | |
| 71 | }) | |
| 72 | }) | |
| 73 | ||
| 74 | describe('reading the box registry', () => { | |
| 75 | it('reads every project on the box, not just one', () => { | |
| 76 | expect(routesFromFragments([STACKS_FRAGMENT, RAPPID_FRAGMENT]).map(r => `${r.slug} ${r.host}${r.path}`)).toEqual([ | |
| 77 | 'rappid rappid.hq.training/', | |
| 78 | 'stacks stacksjs.com/', | |
| 79 | 'stacks stacksjs.com/discord', | |
| 80 | 'stacks stacksjs.com/docs', | |
| 81 | ]) | |
| 82 | }) | |
| 83 | ||
| 84 | it('describes each route by where it actually goes', () => { | |
| 85 | const byPath = Object.fromEntries(routesFromFragments([STACKS_FRAGMENT]).map(r => [r.path, r])) | |
| 86 | ||
| 87 | expect(byPath['/']).toMatchObject({ kind: 'app', target: '127.0.0.1:3000' }) | |
| 88 | expect(byPath['/docs']).toMatchObject({ kind: 'static', target: '/var/www/stacks-docs' }) | |
| 89 | expect(byPath['/discord']).toMatchObject({ kind: 'redirect', target: 'https://discord.gg/example' }) | |
| 90 | }) | |
| 91 | ||
| 92 | it('reads a load-balanced route as its whole upstream pool', () => { | |
| 93 | expect(routesFromFragments([{ slug: 'x', proxies: [{ to: 'x.com', from: ['10.0.0.1:3000', '10.0.0.2:3000'] }] }])[0]) | |
| 94 | .toMatchObject({ kind: 'app', target: '10.0.0.1:3000, 10.0.0.2:3000' }) | |
| 95 | }) | |
| 96 | ||
| 97 | it('defaults a fragment with no slug the way the writer does', () => { | |
| 98 | // An older ts-cloud wrote fragments without `slug`; reading them as an | |
| 99 | // unnamed tenant would split one project in two. | |
| 100 | expect(routesFromFragments([{ proxies: [{ to: 'old.example', from: '127.0.0.1:3000' }] }])[0]?.slug).toBe('app') | |
| 101 | }) | |
| 102 | ||
| 103 | it('groups tenants biggest first so the box owner reads at the top', () => { | |
| 104 | expect(tenantsOf(routesFromFragments([STACKS_FRAGMENT, RAPPID_FRAGMENT])).map(t => t.slug)).toEqual(['stacks', 'rappid']) | |
| 105 | }) | |
| 106 | ||
| 107 | it('reads the same files site-ports does, through the same script', () => { | |
| 108 | // The port allocator and this inventory must never disagree about what is | |
| 109 | // on a box, which is why neither owns its own copy of the read. | |
| 110 | const stdout = [STACKS_FRAGMENT, RAPPID_FRAGMENT] | |
| 111 | .map(fragment => Buffer.from(JSON.stringify(fragment)).toString('base64')) | |
| 112 | .join('\n') | |
| 113 | ||
| 114 | expect(routesFromFragments(parseHostSiteFragments(stdout))).toHaveLength(4) | |
| 115 | expect(buildHostSitePortsScript('/etc/rpx/sites.d')).toContain('/etc/rpx/sites.d') | |
| 116 | }) | |
| 117 | }) | |
| 118 | ||
| 119 | describe('probing a box', () => { | |
| 120 | const server = toInventoryServer({ id: 1, name: 'box', status: 'running', public_net: { ipv4: { ip: '5.5.5.5' } }, labels: {} }) | |
| 121 | ||
| 122 | it('returns the routes a reachable box reports', async () => { | |
| 123 | const probe = await probeHostRoutes(server, async () => ({ | |
| 124 | code: 0, | |
| 125 | stdout: `${Buffer.from(JSON.stringify(STACKS_FRAGMENT)).toString('base64')}\n`, | |
| 126 | stderr: '', | |
| 127 | })) | |
| 128 | ||
| 129 | expect(probe.unavailable).toBeUndefined() | |
| 130 | expect(probe.routes).toHaveLength(3) | |
| 131 | }) | |
| 132 | ||
| 133 | it('reports an unreachable box instead of failing the whole listing', async () => { | |
| 134 | const probe = await probeHostRoutes(server, async () => { | |
| 135 | throw new Error('Permission denied (publickey).\nssh gave up') | |
| 136 | }) | |
| 137 | ||
| 138 | expect(probe).toMatchObject({ routes: [], unavailable: 'Permission denied (publickey).' }) | |
| 139 | }) | |
| 140 | ||
| 141 | it('does not reach for a box that is powered off', async () => { | |
| 142 | let attempted = false | |
| 143 | const probe = await probeHostRoutes({ ...server, status: 'off' }, async () => { | |
| 144 | attempted = true | |
| 145 | return { code: 0, stdout: '', stderr: '' } | |
| 146 | }) | |
| 147 | ||
| 148 | expect(attempted).toBe(false) | |
| 149 | expect(probe.unavailable).toBe('server is off') | |
| 150 | }) | |
| 151 | ||
| 152 | it('surfaces the remote stderr when the command itself fails', async () => { | |
| 153 | const probe = await probeHostRoutes(server, async () => ({ code: 1, stdout: '', stderr: 'find: permission denied\n' })) | |
| 154 | ||
| 155 | expect(probe.unavailable).toBe('find: permission denied') | |
| 156 | }) | |
| 157 | }) | |
| 158 | ||
| 159 | describe('reconciling declared sites against a box', () => { | |
| 160 | const declared = [ | |
| 161 | site('main', { domain: 'stacksjs.com', path: '/' }), | |
| 162 | site('docs', { domain: 'stacksjs.com', path: '/docs' }), | |
| 163 | site('blog', { domain: 'stacksjs.com', path: '/blog' }), | |
| 164 | site('api', { port: 3008 }), | |
| 165 | ] | |
| 166 | const routes = routesFromFragments([STACKS_FRAGMENT, RAPPID_FRAGMENT]) | |
| 167 | ||
| 168 | it('separates present, absent, loopback and somebody else entirely', () => { | |
| 169 | const result = reconcile(declared, routes, 'stacks') | |
| 170 | ||
| 171 | expect(result.present.map(s => s.name).sort()).toEqual(['docs', 'main']) | |
| 172 | expect(result.absent.map(s => s.name)).toEqual(['blog']) | |
| 173 | expect(result.loopback.map(s => s.name)).toEqual(['api']) | |
| 174 | expect(result.foreign.map(r => r.slug)).toEqual(['rappid']) | |
| 175 | }) | |
| 176 | ||
| 177 | it('matches on host and path, not on the site key', () => { | |
| 178 | // The box has no idea what a repository calls its sites, and two projects | |
| 179 | // both naming one `main` is ordinary. | |
| 180 | expect(reconcile([site('frontend', { domain: 'stacksjs.com', path: '/' })], routes, 'stacks').present.map(s => s.name)) | |
| 181 | .toEqual(['frontend']) | |
| 182 | }) | |
| 183 | ||
| 184 | it('does not credit our site to another project serving the same host', () => { | |
| 185 | expect(reconcile([site('main', { domain: 'rappid.hq.training', path: '/' })], routes, 'stacks').absent.map(s => s.name)) | |
| 186 | .toEqual(['main']) | |
| 187 | }) | |
| 188 | ||
| 189 | it('ignores a trailing slash on a path prefix', () => { | |
| 190 | expect(reconcile([site('docs', { domain: 'stacksjs.com', path: '/docs/' })], routes, 'stacks').present).toHaveLength(1) | |
| 191 | }) | |
| 192 | ||
| 193 | it('only counts a site missing when no answering box serves it', () => { | |
| 194 | const probes = [ | |
| 195 | { server: 'a', routes: routesFromFragments([STACKS_FRAGMENT]) }, | |
| 196 | { server: 'b', routes: routesFromFragments([RAPPID_FRAGMENT]) }, | |
| 197 | ] | |
| 198 | ||
| 199 | expect(unaccountedSites(declared, probes, 'stacks').map(s => s.name)).toEqual(['blog']) | |
| 200 | }) | |
| 201 | }) | |
| 202 | ||
| 203 | describe('the listing an operator reads', () => { | |
| 204 | const servers = [toInventoryServer({ | |
| 205 | id: 1, | |
| 206 | name: 'stacks-production-app', | |
| 207 | status: 'running', | |
| 208 | public_net: { ipv4: { ip: '5.161.0.1' } }, | |
| 209 | server_type: { name: 'cpx41' }, | |
| 210 | labels: { 'ts-cloud/project': 'stacks', 'ts-cloud/environment': 'production', 'ts-cloud/role': 'app' }, | |
| 211 | })] | |
| 212 | ||
| 213 | const declared = [ | |
| 214 | site('main', { domain: 'stacksjs.com', path: '/' }), | |
| 215 | site('blog', { domain: 'blog.example', path: '/' }), | |
| 216 | site('api', { port: 3008 }), | |
| 217 | ] | |
| 218 | ||
| 219 | it('names the co-tenant sharing the box', () => { | |
| 220 | const output = formatInventory({ | |
| 221 | slug: 'stacks', | |
| 222 | servers, | |
| 223 | probes: [{ server: 'stacks-production-app', routes: routesFromFragments([STACKS_FRAGMENT, RAPPID_FRAGMENT]) }], | |
| 224 | declared, | |
| 225 | }).join('\n') | |
| 226 | ||
| 227 | expect(output).toContain('serves 4 routes for 2 projects') | |
| 228 | expect(output).toContain('stacks (this project)') | |
| 229 | expect(output).toContain('rappid.hq.training/ -> 127.0.0.1:3024') | |
| 230 | }) | |
| 231 | ||
| 232 | it('says a box was not probed rather than implying it hosts nothing', () => { | |
| 233 | const output = formatInventory({ slug: 'stacks', servers, probes: [], declared }).join('\n') | |
| 234 | ||
| 235 | expect(output).toContain('not probed') | |
| 236 | expect(output).toContain('Nothing to reconcile them against') | |
| 237 | }) | |
| 238 | ||
| 239 | it('refuses to reconcile against a box that could not be read', () => { | |
| 240 | // "Every site is missing" is a true statement about the listing and a false | |
| 241 | // one about the deployment. | |
| 242 | const output = formatInventory({ | |
| 243 | slug: 'stacks', | |
| 244 | servers, | |
| 245 | probes: [{ server: 'stacks-production-app', routes: [], unavailable: 'Permission denied (publickey).' }], | |
| 246 | declared, | |
| 247 | }).join('\n') | |
| 248 | ||
| 249 | expect(output).toContain('could not read /etc/rpx/sites.d: Permission denied (publickey).') | |
| 250 | expect(output).toContain('Nothing to reconcile them against') | |
| 251 | expect(output).not.toContain('not routed by any box above') | |
| 252 | }) | |
| 253 | ||
| 254 | it('blames an unreadable box before it blames the deploy', () => { | |
| 255 | const output = formatInventory({ | |
| 256 | slug: 'stacks', | |
| 257 | servers: [...servers, toInventoryServer({ id: 2, name: 'other', status: 'running', labels: {} })], | |
| 258 | probes: [ | |
| 259 | { server: 'stacks-production-app', routes: routesFromFragments([STACKS_FRAGMENT]) }, | |
| 260 | { server: 'other', routes: [], unavailable: 'Permission denied (publickey).' }, | |
| 261 | ], | |
| 262 | declared, | |
| 263 | }).join('\n') | |
| 264 | ||
| 265 | expect(output).toContain('1 not routed by any box above: blog') | |
| 266 | expect(output).toContain('one of the 1 server that could not be read') | |
| 267 | }) | |
| 268 | ||
| 269 | it('explains a domainless site instead of listing it as missing', () => { | |
| 270 | const output = formatInventory({ | |
| 271 | slug: 'stacks', | |
| 272 | servers, | |
| 273 | probes: [{ server: 'stacks-production-app', routes: routesFromFragments([STACKS_FRAGMENT]) }], | |
| 274 | declared, | |
| 275 | }).join('\n') | |
| 276 | ||
| 277 | expect(output).toContain('1 with no domain') | |
| 278 | expect(output).toContain('1 not routed by any box above: blog') | |
| 279 | }) | |
| 280 | }) | |
| @@ -0,0 +1,430 @@ | ||
| 1 | /** | |
| 2 | * What is hosted on which box, across every project sharing it. | |
| 3 | * | |
| 4 | * Consolidating servers starts with a question nothing could answer: what is | |
| 5 | * actually running on each one? A project's own config cannot answer it. It | |
| 6 | * describes ONE project's sites, and the boxes are multi-tenant - other | |
| 7 | * projects deploy onto them with `cloud.attachTo`, from their own repositories, | |
| 8 | * and appear in no file the first project owns. Reading config and calling it | |
| 9 | * an inventory reports a shared box as if one project were alone on it, which | |
| 10 | * is precisely the wrong answer to consolidate against. | |
| 11 | * | |
| 12 | * So the answer comes from the box. Each project's deploy writes an rpx | |
| 13 | * registry fragment into `HOST_SITES_DIR`, and those files together are the | |
| 14 | * only complete record of what the host serves and for whom. `site-ports` | |
| 15 | * already reads them to allocate ports around co-tenants; this module reads | |
| 16 | * the same files for the routes rather than the ports, so the two can never | |
| 17 | * disagree about what is on a box. | |
| 18 | * | |
| 19 | * Everything here is pure except {@link probeHostRoutes}, which takes its exec | |
| 20 | * as an argument - the same shape `site-move` uses, so an inventory can be | |
| 21 | * tested without a box, a provider, or a credential. | |
| 22 | * | |
| 23 | * @see https://github.com/stacksjs/ts-cloud/issues/167 | |
| 24 | * @see https://github.com/stacksjs/stacks/issues/2342 | |
| 25 | */ | |
| 26 | ||
| 27 | import type { HostSiteFragment } from '../deploy/site-ports' | |
| 28 | import { buildHostSitePortsScript, HOST_SITES_DIR, parseHostSiteFragments } from '../deploy/site-ports' | |
| 29 | ||
| 30 | /** The ts-cloud labels every provisioned box carries. */ | |
| 31 | export const PROJECT_LABEL = 'ts-cloud/project' | |
| 32 | export const ENVIRONMENT_LABEL = 'ts-cloud/environment' | |
| 33 | export const ROLE_LABEL = 'ts-cloud/role' | |
| 34 | ||
| 35 | /** | |
| 36 | * A server in the inventory, reduced to what deciding a consolidation needs. | |
| 37 | * | |
| 38 | * Structural and provider-agnostic on purpose: a Hetzner server satisfies it | |
| 39 | * after {@link toInventoryServer}, and another driver can satisfy it without | |
| 40 | * importing anything from here. | |
| 41 | */ | |
| 42 | export interface InventoryServer { | |
| 43 | id: string | |
| 44 | name: string | |
| 45 | status: string | |
| 46 | ipv4?: string | |
| 47 | ipv6?: string | |
| 48 | type?: string | |
| 49 | location?: string | |
| 50 | labels: Record<string, string> | |
| 51 | /** `ts-cloud/project`: the project that provisioned and owns this box. */ | |
| 52 | project?: string | |
| 53 | /** `ts-cloud/environment`: production, staging, ... */ | |
| 54 | environment?: string | |
| 55 | /** `ts-cloud/role`: app, services, lb. */ | |
| 56 | role?: string | |
| 57 | } | |
| 58 | ||
| 59 | /** One route a box serves, and the project whose fragment declared it. */ | |
| 60 | export interface HostedRoute { | |
| 61 | slug: string | |
| 62 | host: string | |
| 63 | path: string | |
| 64 | /** Where the route goes: an upstream, a served directory, or a redirect target. */ | |
| 65 | target: string | |
| 66 | kind: 'app' | 'static' | 'redirect' | 'unknown' | |
| 67 | } | |
| 68 | ||
| 69 | /** What a box answered when asked what it serves. */ | |
| 70 | export interface HostProbe { | |
| 71 | server: string | |
| 72 | ip?: string | |
| 73 | routes: HostedRoute[] | |
| 74 | /** Why the probe produced nothing, when it produced nothing. */ | |
| 75 | unavailable?: string | |
| 76 | } | |
| 77 | ||
| 78 | function text(value: unknown): string | undefined { | |
| 79 | return typeof value === 'string' && value.trim() ? value.trim() : undefined | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Shape one provider server record into {@link InventoryServer}. | |
| 84 | * | |
| 85 | * Written against the Hetzner payload, but touches only fields any provider | |
| 86 | * listing carries, so a driver with a different shape maps onto the same type | |
| 87 | * rather than forcing a second one. | |
| 88 | * | |
| 89 | * An unlabelled box is kept rather than dropped. A server provisioned by hand, | |
| 90 | * or by a ts-cloud old enough not to have labelled it, is exactly the kind a | |
| 91 | * consolidation needs to see; requiring the labels would hide it. | |
| 92 | */ | |
| 93 | export function toInventoryServer(raw: any): InventoryServer { | |
| 94 | const labels: Record<string, string> = {} | |
| 95 | for (const [key, value] of Object.entries(raw?.labels ?? {})) { | |
| 96 | if (typeof value === 'string') labels[key] = value | |
| 97 | } | |
| 98 | ||
| 99 | return { | |
| 100 | id: String(raw?.id ?? ''), | |
| 101 | name: text(raw?.name) ?? '(unnamed)', | |
| 102 | status: text(raw?.status) ?? 'unknown', | |
| 103 | ipv4: text(raw?.public_net?.ipv4?.ip) ?? text(raw?.ipv4), | |
| 104 | ipv6: text(raw?.public_net?.ipv6?.ip) ?? text(raw?.ipv6), | |
| 105 | type: text(raw?.server_type?.name) ?? text(raw?.type), | |
| 106 | location: text(raw?.datacenter?.location?.name) ?? text(raw?.datacenter?.name) ?? text(raw?.location), | |
| 107 | labels, | |
| 108 | project: text(labels[PROJECT_LABEL]), | |
| 109 | environment: text(labels[ENVIRONMENT_LABEL]), | |
| 110 | role: text(labels[ROLE_LABEL]), | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | /** The slug a fragment belongs to, matching the writer's `'app'` default. */ | |
| 115 | function fragmentSlug(fragment: HostSiteFragment): string { | |
| 116 | return fragment.slug?.trim() || 'app' | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Flatten registry fragments into one route list, sorted so two runs against an | |
| 121 | * unchanged box produce an identical listing. | |
| 122 | */ | |
| 123 | export function routesFromFragments(fragments: readonly HostSiteFragment[]): HostedRoute[] { | |
| 124 | const routes: HostedRoute[] = [] | |
| 125 | ||
| 126 | for (const fragment of fragments) { | |
| 127 | const slug = fragmentSlug(fragment) | |
| 128 | ||
| 129 | for (const proxy of fragment.proxies ?? []) { | |
| 130 | const host = text(proxy?.to) | |
| 131 | if (!host) continue | |
| 132 | ||
| 133 | routes.push({ slug, host, path: text(proxy?.path) ?? '/', ...describeRouteTarget(proxy) }) | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | return routes.sort((a, b) => | |
| 138 | a.slug.localeCompare(b.slug) || a.host.localeCompare(b.host) || a.path.localeCompare(b.path)) | |
| 139 | } | |
| 140 | ||
| 141 | function describeRouteTarget(proxy: NonNullable<HostSiteFragment['proxies']>[number]): Pick<HostedRoute, 'target' | 'kind'> { | |
| 142 | const from = proxy?.from | |
| 143 | if (typeof from === 'string' && from.trim()) return { target: from.trim(), kind: 'app' } | |
| 144 | if (Array.isArray(from) && from.length > 0) { | |
| 145 | return { target: from.filter(upstream => typeof upstream === 'string').join(', '), kind: 'app' } | |
| 146 | } | |
| 147 | ||
| 148 | const served = proxy?.static | |
| 149 | if (typeof served === 'string' && served.trim()) return { target: served.trim(), kind: 'static' } | |
| 150 | if (served && typeof served === 'object' && text(served.dir)) return { target: text(served.dir)!, kind: 'static' } | |
| 151 | ||
| 152 | const redirect = typeof proxy?.redirect === 'string' ? text(proxy.redirect) : text(proxy?.redirect?.to) | |
| 153 | if (redirect) return { target: redirect, kind: 'redirect' } | |
| 154 | ||
| 155 | return { target: '(no upstream)', kind: 'unknown' } | |
| 156 | } | |
| 157 | ||
| 158 | /** Group routes by the project that owns them, biggest tenant first. */ | |
| 159 | export function tenantsOf(routes: readonly HostedRoute[]): Array<{ slug: string, routes: HostedRoute[] }> { | |
| 160 | const bySlug = new Map<string, HostedRoute[]>() | |
| 161 | for (const route of routes) { | |
| 162 | const bucket = bySlug.get(route.slug) | |
| 163 | if (bucket) bucket.push(route) | |
| 164 | else bySlug.set(route.slug, [route]) | |
| 165 | } | |
| 166 | ||
| 167 | return [...bySlug.entries()] | |
| 168 | .map(([slug, grouped]) => ({ slug, routes: grouped })) | |
| 169 | .sort((a, b) => b.routes.length - a.routes.length || a.slug.localeCompare(b.slug)) | |
| 170 | } | |
| 171 | ||
| 172 | /** Run a script on a host, resolving with its exit code and output. */ | |
| 173 | export type InventoryExec = (host: string, command: string) => Promise<{ code: number, stdout: string, stderr: string }> | |
| 174 | ||
| 175 | /** | |
| 176 | * Ask one box what it serves. | |
| 177 | * | |
| 178 | * Never throws. A box that is off, unreachable, or refuses the key comes back | |
| 179 | * with an `unavailable` reason instead, so one bad server does not cost the | |
| 180 | * listing of every other one - and so a caller can tell "serves nothing" apart | |
| 181 | * from "was never asked", which are the two answers most worth not confusing. | |
| 182 | */ | |
| 183 | export async function probeHostRoutes( | |
| 184 | server: InventoryServer, | |
| 185 | exec: InventoryExec, | |
| 186 | sitesDir: string = HOST_SITES_DIR, | |
| 187 | ): Promise<HostProbe> { | |
| 188 | if (!server.ipv4) { | |
| 189 | return { server: server.name, routes: [], unavailable: 'no public IPv4 address to reach it on' } | |
| 190 | } | |
| 191 | ||
| 192 | if (server.status !== 'running') { | |
| 193 | return { server: server.name, ip: server.ipv4, routes: [], unavailable: `server is ${server.status}` } | |
| 194 | } | |
| 195 | ||
| 196 | try { | |
| 197 | const result = await exec(server.ipv4, buildHostSitePortsScript(sitesDir)) | |
| 198 | if (result.code !== 0) { | |
| 199 | const reason = result.stderr.trim().split('\n')[0] || `remote command exited ${result.code}` | |
| 200 | return { server: server.name, ip: server.ipv4, routes: [], unavailable: reason } | |
| 201 | } | |
| 202 | ||
| 203 | return { server: server.name, ip: server.ipv4, routes: routesFromFragments(parseHostSiteFragments(result.stdout)) } | |
| 204 | } | |
| 205 | catch (error) { | |
| 206 | const reason = error instanceof Error ? error.message.split('\n')[0] : String(error) | |
| 207 | return { server: server.name, ip: server.ipv4, routes: [], unavailable: reason } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /** One site a project declares, in the terms the box reports. */ | |
| 212 | export interface DeclaredSite { | |
| 213 | name: string | |
| 214 | kind?: string | |
| 215 | domain?: string | |
| 216 | path: string | |
| 217 | port?: number | |
| 218 | /** `siteInstallBase(slug, name)`, when the caller resolved it. */ | |
| 219 | installBase?: string | |
| 220 | /** | |
| 221 | * No `domain`, so the gateway never routes it and it cannot appear in a | |
| 222 | * registry fragment. That is a deliberate configuration - a loopback-only | |
| 223 | * service reached through another site's proxy - not a missing deploy. | |
| 224 | */ | |
| 225 | loopbackOnly: boolean | |
| 226 | } | |
| 227 | ||
| 228 | /** Declared sites lined up against what one box actually serves. */ | |
| 229 | export interface Reconciliation { | |
| 230 | /** Declared and present on the box. */ | |
| 231 | present: DeclaredSite[] | |
| 232 | /** Declared, routable, and absent from the box's registry. */ | |
| 233 | absent: DeclaredSite[] | |
| 234 | /** Declared with no domain: no gateway route by design. */ | |
| 235 | loopback: DeclaredSite[] | |
| 236 | /** Routes on the box owned by some other project. */ | |
| 237 | foreign: HostedRoute[] | |
| 238 | } | |
| 239 | ||
| 240 | function routeKey(host: string, path: string): string { | |
| 241 | const normalized = path === '/' ? '/' : path.replace(/\/+$/, '') | |
| 242 | return `${host.toLowerCase()}${normalized || '/'}` | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Line a project's declared sites up against what a box serves. | |
| 247 | * | |
| 248 | * Matched on host and path rather than on the site key, because the site key is | |
| 249 | * local to a repository and the box has no idea what it is. Two projects both | |
| 250 | * calling a site `main` is ordinary; two projects serving the same host and | |
| 251 | * path is the collision worth seeing. | |
| 252 | */ | |
| 253 | export function reconcile( | |
| 254 | declared: readonly DeclaredSite[], | |
| 255 | routes: readonly HostedRoute[], | |
| 256 | slug: string, | |
| 257 | ): Reconciliation { | |
| 258 | const ours = new Set(routes.filter(route => route.slug === slug).map(route => routeKey(route.host, route.path))) | |
| 259 | ||
| 260 | const present: DeclaredSite[] = [] | |
| 261 | const absent: DeclaredSite[] = [] | |
| 262 | const loopback: DeclaredSite[] = [] | |
| 263 | ||
| 264 | for (const site of declared) { | |
| 265 | if (site.loopbackOnly) loopback.push(site) | |
| 266 | else if (site.domain && ours.has(routeKey(site.domain, site.path))) present.push(site) | |
| 267 | else absent.push(site) | |
| 268 | } | |
| 269 | ||
| 270 | return { present, absent, loopback, foreign: routes.filter(route => route.slug !== slug) } | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Sites no probed box accounts for. | |
| 275 | * | |
| 276 | * Callers must pass only the probes that ANSWERED. A box that could not be read | |
| 277 | * proves nothing about what it holds, and counting it would report every site | |
| 278 | * on it as missing - a true statement about the listing and a false one about | |
| 279 | * the deployment. | |
| 280 | */ | |
| 281 | export function unaccountedSites( | |
| 282 | declared: readonly DeclaredSite[], | |
| 283 | answered: readonly HostProbe[], | |
| 284 | slug: string, | |
| 285 | ): DeclaredSite[] { | |
| 286 | const seen = new Set<string>() | |
| 287 | for (const probe of answered) { | |
| 288 | for (const route of probe.routes) { | |
| 289 | if (route.slug === slug) seen.add(routeKey(route.host, route.path)) | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | return declared.filter(site => !site.loopbackOnly && site.domain !== undefined && !seen.has(routeKey(site.domain, site.path))) | |
| 294 | } | |
| 295 | ||
| 296 | export interface Inventory { | |
| 297 | slug: string | |
| 298 | servers: InventoryServer[] | |
| 299 | probes: HostProbe[] | |
| 300 | declared: DeclaredSite[] | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * The inventory as lines, ready to print. | |
| 305 | * | |
| 306 | * Lines rather than printed output, matching {@link import('./plan').formatPlan}: | |
| 307 | * the caller owns the stream, and the format stays testable. Every count is | |
| 308 | * stated so a partial answer reads as partial - a box that could not be read | |
| 309 | * says so on its own line, and the summary separates "not deployed" from "not | |
| 310 | * visible from here". | |
| 311 | */ | |
| 312 | export function formatInventory(inventory: Inventory): string[] { | |
| 313 | const { slug, servers, probes, declared } = inventory | |
| 314 | const lines: string[] = [] | |
| 315 | ||
| 316 | lines.push(servers.length === 0 | |
| 317 | ? 'No servers found.' | |
| 318 | : `${servers.length} server${servers.length === 1 ? '' : 's'}:`) | |
| 319 | if (servers.length > 0) lines.push('') | |
| 320 | ||
| 321 | const probesByServer = new Map(probes.map(probe => [probe.server, probe])) | |
| 322 | ||
| 323 | for (const server of servers) { | |
| 324 | const facts = [server.ipv4, server.type, server.location, server.status].filter(Boolean) | |
| 325 | lines.push(` ${server.name} ${facts.join(' ')}`) | |
| 326 | lines.push(` ${describeOwnership(server)}`) | |
| 327 | ||
| 328 | const probe = probesByServer.get(server.name) | |
| 329 | if (!probe) { | |
| 330 | lines.push(' not probed, so co-tenants on this box are not listed') | |
| 331 | } | |
| 332 | else if (probe.unavailable) { | |
| 333 | lines.push(` could not read ${HOST_SITES_DIR}: ${probe.unavailable}`) | |
| 334 | } | |
| 335 | else { | |
| 336 | lines.push(...describeTenants(probe, slug)) | |
| 337 | } | |
| 338 | ||
| 339 | lines.push('') | |
| 340 | } | |
| 341 | ||
| 342 | lines.push(...describeDeclared(inventory)) | |
| 343 | ||
| 344 | return lines | |
| 345 | } | |
| 346 | ||
| 347 | function describeOwnership(server: InventoryServer): string { | |
| 348 | if (!server.project) { | |
| 349 | return 'no ts-cloud labels: provisioned outside ts-cloud, or by a version that did not label boxes' | |
| 350 | } | |
| 351 | ||
| 352 | const detail = [server.environment, server.role && `role ${server.role}`].filter(Boolean).join(', ') | |
| 353 | return `owned by '${server.project}'${detail ? ` (${detail})` : ''}` | |
| 354 | } | |
| 355 | ||
| 356 | function describeTenants(probe: HostProbe, slug: string): string[] { | |
| 357 | const tenants = tenantsOf(probe.routes) | |
| 358 | if (tenants.length === 0) return [` serves nothing: ${HOST_SITES_DIR} is empty or absent`] | |
| 359 | ||
| 360 | const lines = [ | |
| 361 | ` serves ${probe.routes.length} route${probe.routes.length === 1 ? '' : 's'} ` | |
| 362 | + `for ${tenants.length} project${tenants.length === 1 ? '' : 's'}:`, | |
| 363 | ] | |
| 364 | ||
| 365 | for (const tenant of tenants) { | |
| 366 | lines.push(` ${tenant.slug}${tenant.slug === slug ? ' (this project)' : ''}`) | |
| 367 | for (const route of tenant.routes) { | |
| 368 | lines.push(` ${route.host}${route.path === '/' ? '/' : route.path} -> ${describeTarget(route)}`) | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | return lines | |
| 373 | } | |
| 374 | ||
| 375 | function describeTarget(route: HostedRoute): string { | |
| 376 | if (route.kind === 'redirect') return `redirect to ${route.target}` | |
| 377 | if (route.kind === 'static') return `static ${route.target}` | |
| 378 | return route.target | |
| 379 | } | |
| 380 | ||
| 381 | function describeDeclared(inventory: Inventory): string[] { | |
| 382 | const { slug, declared, probes, servers } = inventory | |
| 383 | if (declared.length === 0) return [`This project ('${slug}') declares no sites.`] | |
| 384 | ||
| 385 | const lines = [ | |
| 386 | `This project ('${slug}') declares ${declared.length} site${declared.length === 1 ? '' : 's'}: ` | |
| 387 | + declared.map(site => site.name).join(', '), | |
| 388 | ] | |
| 389 | ||
| 390 | const loopback = declared.filter(site => site.loopbackOnly) | |
| 391 | if (loopback.length > 0) { | |
| 392 | lines.push( | |
| 393 | ` ${loopback.length} with no domain, so the gateway never routes ${loopback.length === 1 ? 'it' : 'them'} ` | |
| 394 | + `(reached through another site's proxy): ${loopback.map(site => site.name).join(', ')}`, | |
| 395 | ) | |
| 396 | } | |
| 397 | ||
| 398 | // Reconciliation needs at least one box that actually answered. Without one, | |
| 399 | // every routable site is "not found", which is the shape of wrong answer this | |
| 400 | // module exists to stop producing. | |
| 401 | const answered = probes.filter(probe => !probe.unavailable) | |
| 402 | if (answered.length === 0) { | |
| 403 | lines.push(' Nothing to reconcile them against: no box reported what it serves.') | |
| 404 | return lines | |
| 405 | } | |
| 406 | ||
| 407 | const unaccounted = unaccountedSites(declared, answered, slug) | |
| 408 | lines.push(` ${declared.length - loopback.length - unaccounted.length} routed by a box above`) | |
| 409 | ||
| 410 | if (unaccounted.length > 0) { | |
| 411 | // Two very different causes, and this listing cannot tell them apart, so it | |
| 412 | // must not pick one: an undeployed site and a site on a box that was not | |
| 413 | // read look identical from here. | |
| 414 | lines.push(` ${unaccounted.length} not routed by any box above: ${unaccounted.map(site => site.name).join(', ')}`) | |
| 415 | ||
| 416 | const unread = probes.length - answered.length | |
| 417 | const unprobed = servers.length - probes.length | |
| 418 | if (unread > 0) { | |
| 419 | lines.push(` Either they were never deployed, or they are on one of the ${unread} server${unread === 1 ? '' : 's'} that could not be read.`) | |
| 420 | } | |
| 421 | else if (unprobed > 0) { | |
| 422 | lines.push(` Either they were never deployed, or they are on one of the ${unprobed} server${unprobed === 1 ? '' : 's'} this run did not probe.`) | |
| 423 | } | |
| 424 | else { | |
| 425 | lines.push(' Either they were never deployed, or they are on a server this listing did not cover.') | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 429 | return lines | |
| 430 | } | |
| @@ -0,0 +1,222 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { setAttachToInCloudConfig } from '../deploy/site-config-editor' | |
| 3 | import { routesFromFragments, toInventoryServer } from './inventory' | |
| 4 | import { attachConflicts, attachIsViable, attachPreconditions, formatAttachPlan, resolveAttachTarget } from './site-attach' | |
| 5 | ||
| 6 | /** | |
| 7 | * The failure this is built around is not hypothetical: two services on one | |
| 8 | * port do not error, because the units do not bind exclusively. The kernel | |
| 9 | * load-balances between them, both look healthy, and each domain serves the | |
| 10 | * other project's site about half the time. predicthq.org spent a day and a | |
| 11 | * half like that. Every check here exists to catch it before a deploy rather | |
| 12 | * than during one. | |
| 13 | */ | |
| 14 | ||
| 15 | function server(overrides: Record<string, any> = {}) { | |
| 16 | return toInventoryServer({ | |
| 17 | id: 1, | |
| 18 | name: 'stacks-production-app', | |
| 19 | status: 'running', | |
| 20 | public_net: { ipv4: { ip: '5.161.0.1' } }, | |
| 21 | labels: { 'ts-cloud/project': 'stacks', 'ts-cloud/environment': 'production' }, | |
| 22 | ...overrides, | |
| 23 | }) | |
| 24 | } | |
| 25 | ||
| 26 | function site(name: string, overrides: Record<string, any> = {}) { | |
| 27 | return { name, path: '/', loopbackOnly: overrides.domain === undefined, ...overrides } | |
| 28 | } | |
| 29 | ||
| 30 | const BOX_ROUTES = routesFromFragments([ | |
| 31 | { | |
| 32 | slug: 'stacks', | |
| 33 | proxies: [ | |
| 34 | { to: 'stacksjs.com', from: '127.0.0.1:3000' }, | |
| 35 | { to: 'stacksjs.com', path: '/docs', static: { dir: '/var/www/stacks-docs' } }, | |
| 36 | ], | |
| 37 | }, | |
| 38 | { slug: 'rappid', proxies: [{ to: 'rappid.hq.training', from: '127.0.0.1:3024' }] }, | |
| 39 | ]) | |
| 40 | ||
| 41 | describe('picking the server to attach to', () => { | |
| 42 | const servers = [ | |
| 43 | server(), | |
| 44 | toInventoryServer({ id: 2, name: 'stacks-staging-app', status: 'running', labels: { 'ts-cloud/project': 'stacks', 'ts-cloud/environment': 'staging' } }), | |
| 45 | toInventoryServer({ id: 3, name: 'bughq-production-app', status: 'running', labels: { 'ts-cloud/project': 'bughq' } }), | |
| 46 | ] | |
| 47 | ||
| 48 | it('accepts the provider name an operator reads off the console', () => { | |
| 49 | expect(resolveAttachTarget(servers, 'bughq-production-app')).toMatchObject({ server: { name: 'bughq-production-app' } }) | |
| 50 | }) | |
| 51 | ||
| 52 | it('accepts the owner slug that attachTo actually takes', () => { | |
| 53 | expect(resolveAttachTarget(servers, 'bughq')).toMatchObject({ server: { name: 'bughq-production-app' } }) | |
| 54 | }) | |
| 55 | ||
| 56 | it('refuses rather than guessing when one owner has several boxes', () => { | |
| 57 | expect(resolveAttachTarget(servers, 'stacks')).toMatchObject({ problem: expect.stringContaining('owns 2 servers') }) | |
| 58 | }) | |
| 59 | ||
| 60 | it('narrows several boxes by environment when one is given', () => { | |
| 61 | expect(resolveAttachTarget(servers, 'stacks', 'staging')).toMatchObject({ server: { name: 'stacks-staging-app' } }) | |
| 62 | }) | |
| 63 | ||
| 64 | it('says where to look when nothing matched', () => { | |
| 65 | expect(resolveAttachTarget(servers, 'nope')).toMatchObject({ problem: expect.stringContaining('ts-cloud/project=nope') }) | |
| 66 | }) | |
| 67 | }) | |
| 68 | ||
| 69 | describe('preconditions', () => { | |
| 70 | it('refuses a box ts-cloud does not manage', () => { | |
| 71 | const unmanaged = toInventoryServer({ id: 9, name: 'hand-rolled', status: 'running', public_net: { ipv4: { ip: '1.1.1.1' } }, labels: {} }) | |
| 72 | ||
| 73 | expect(attachPreconditions('rappid', unmanaged)[0]).toContain('no ts-cloud/project label') | |
| 74 | }) | |
| 75 | ||
| 76 | it('refuses a tenant whose slug is the box owner\'s', () => { | |
| 77 | // A tenant deploy owns the gateway fragment named after its slug, so | |
| 78 | // sharing the owner's slug overwrites the owner's fragment. | |
| 79 | expect(attachPreconditions('stacks', server())[0]).toContain('also the slug that owns') | |
| 80 | }) | |
| 81 | ||
| 82 | it('refuses a box that is not running, because nothing can be checked against it', () => { | |
| 83 | expect(attachPreconditions('rappid', server({ status: 'off' }))[0]).toContain('is off') | |
| 84 | }) | |
| 85 | ||
| 86 | it('passes a healthy box owned by somebody else', () => { | |
| 87 | expect(attachPreconditions('rappid', server())).toEqual([]) | |
| 88 | }) | |
| 89 | }) | |
| 90 | ||
| 91 | describe('conflicts with what the box already serves', () => { | |
| 92 | it('catches the port clash that does not error on its own', () => { | |
| 93 | expect(attachConflicts('rappid', [site('main', { domain: 'rappid.hq.training', port: 3000 })], BOX_ROUTES)) | |
| 94 | .toContainEqual({ kind: 'port', site: 'main', detail: 'port 3000', heldBy: 'stacks' }) | |
| 95 | }) | |
| 96 | ||
| 97 | it('catches a hostname already served by another project', () => { | |
| 98 | expect(attachConflicts('newproject', [site('main', { domain: 'stacksjs.com', path: '/docs' })], BOX_ROUTES)) | |
| 99 | .toContainEqual({ kind: 'route', site: 'main', detail: 'stacksjs.com/docs', heldBy: 'stacks' }) | |
| 100 | }) | |
| 101 | ||
| 102 | it('does not report a free port as taken', () => { | |
| 103 | expect(attachConflicts('rappid', [site('api', { port: 3099 })], BOX_ROUTES)).toEqual([]) | |
| 104 | }) | |
| 105 | ||
| 106 | it('does not count our own routes against us', () => { | |
| 107 | // Every repeat attach finds its own fragment on the box from the last | |
| 108 | // deploy; counting it would make the second one impossible. | |
| 109 | expect(attachConflicts('rappid', [site('main', { domain: 'rappid.hq.training', port: 3024 })], BOX_ROUTES)).toEqual([]) | |
| 110 | }) | |
| 111 | ||
| 112 | it('reads no port from a static or redirect route', () => { | |
| 113 | // Their targets are paths and URLs, and `https://x.com/y` has a colon. | |
| 114 | const routes = routesFromFragments([{ | |
| 115 | slug: 'x', | |
| 116 | proxies: [{ to: 'a.com', static: { dir: '/var/www/a' } }, { to: 'b.com', redirect: { to: 'https://example.com:443/z' } }], | |
| 117 | }]) | |
| 118 | ||
| 119 | expect(attachConflicts('mine', [site('one', { port: 443 })], routes)).toEqual([]) | |
| 120 | }) | |
| 121 | }) | |
| 122 | ||
| 123 | describe('the plan an operator reads', () => { | |
| 124 | const declared = [ | |
| 125 | site('main', { domain: 'rappid.hq.training', port: 3024, installBase: '/var/www/rappid-main' }), | |
| 126 | site('api', { port: 3008, installBase: '/var/www/rappid-api' }), | |
| 127 | ] | |
| 128 | ||
| 129 | function plan(overrides: Record<string, any> = {}): any { | |
| 130 | return { slug: 'rappid', owner: 'stacks', server: server(), declared, conflicts: [], registryRead: true, ...overrides } | |
| 131 | } | |
| 132 | ||
| 133 | it('will not claim there are no conflicts when the box was never read', () => { | |
| 134 | // "No conflicts" after failing to ask is the single most dangerous thing | |
| 135 | // this could print. | |
| 136 | const output = formatAttachPlan(plan({ registryRead: false, registryProblem: 'Permission denied (publickey).' })).join('\n') | |
| 137 | ||
| 138 | expect(output).toContain('UNCHECKED') | |
| 139 | expect(output).toContain('Permission denied (publickey).') | |
| 140 | expect(output).not.toContain('No conflicts') | |
| 141 | }) | |
| 142 | ||
| 143 | it('explains why a port clash is not a loud failure', () => { | |
| 144 | const output = formatAttachPlan(plan({ conflicts: [{ kind: 'port', site: 'main', detail: 'port 3000', heldBy: 'stacks' }] })).join('\n') | |
| 145 | ||
| 146 | expect(output).toContain('site \'main\' wants port 3000, held by \'stacks\'') | |
| 147 | expect(output).toContain('the kernel load-balances') | |
| 148 | }) | |
| 149 | ||
| 150 | it('shows loopback-only sites as such rather than inventing a hostname', () => { | |
| 151 | expect(formatAttachPlan(plan()).join('\n')).toContain('api loopback only on :3008') | |
| 152 | }) | |
| 153 | ||
| 154 | it('is viable only when the box answered and answered clean', () => { | |
| 155 | expect(attachIsViable(plan())).toBe(true) | |
| 156 | expect(attachIsViable(plan({ registryRead: false }))).toBe(false) | |
| 157 | expect(attachIsViable(plan({ conflicts: [{ kind: 'port', site: 'a', detail: 'port 1', heldBy: 'x' }] }))).toBe(false) | |
| 158 | }) | |
| 159 | }) | |
| 160 | ||
| 161 | describe('writing the attach into a cloud config', () => { | |
| 162 | const CONFIG = `import { env } from '@stacksjs/env' | |
| 163 | ||
| 164 | export const tsCloud = { | |
| 165 | project: { | |
| 166 | name: 'app', | |
| 167 | slug: 'app', | |
| 168 | }, | |
| 169 | ||
| 170 | cloud: { | |
| 171 | provider: 'hetzner', | |
| 172 | }, | |
| 173 | ||
| 174 | mode: 'server', | |
| 175 | } | |
| 176 | ` | |
| 177 | ||
| 178 | it('adds attachTo to the shape the templates generate', () => { | |
| 179 | const text = setAttachToInCloudConfig({ configText: CONFIG, owner: 'stacks' }) | |
| 180 | ||
| 181 | expect(text).toContain('attachTo: \'stacks\',') | |
| 182 | expect(text).toContain('provider: \'hetzner\',') | |
| 183 | ||
| 184 | // Nothing else moved: strip the two added lines and the original comes back | |
| 185 | // byte for byte, so a config full of comments cannot be quietly reflowed. | |
| 186 | const kept = text.split('\n') | |
| 187 | for (const line of [' // Deploy onto the box \'stacks\' owns rather than provisioning one.', ' attachTo: \'stacks\',']) { | |
| 188 | const at = kept.indexOf(line) | |
| 189 | expect(at).toBeGreaterThan(-1) | |
| 190 | kept.splice(at, 1) | |
| 191 | } | |
| 192 | expect(kept.join('\n')).toBe(CONFIG) | |
| 193 | }) | |
| 194 | ||
| 195 | it('is a no-op when it already attaches to that owner', () => { | |
| 196 | const once = setAttachToInCloudConfig({ configText: CONFIG, owner: 'stacks' }) | |
| 197 | ||
| 198 | expect(setAttachToInCloudConfig({ configText: once, owner: 'stacks' })).toBe(once) | |
| 199 | }) | |
| 200 | ||
| 201 | it('repoints an existing attachTo at a different owner', () => { | |
| 202 | const once = setAttachToInCloudConfig({ configText: CONFIG, owner: 'stacks' }) | |
| 203 | const moved = setAttachToInCloudConfig({ configText: once, owner: 'bughq' }) | |
| 204 | ||
| 205 | expect(moved).toContain('attachTo: \'bughq\',') | |
| 206 | expect(moved).not.toContain('attachTo: \'stacks\',') | |
| 207 | }) | |
| 208 | ||
| 209 | it('refuses a cloud block holding a nested object rather than guessing', () => { | |
| 210 | const nested = CONFIG.replace(' provider: \'hetzner\',', ' provider: \'hetzner\',\n hetzner: { location: \'fsn1\' },') | |
| 211 | ||
| 212 | expect(() => setAttachToInCloudConfig({ configText: nested, owner: 'stacks' })).toThrow('nested object') | |
| 213 | }) | |
| 214 | ||
| 215 | it('refuses when there is more than one cloud block', () => { | |
| 216 | expect(() => setAttachToInCloudConfig({ configText: CONFIG + CONFIG, owner: 'stacks' })).toThrow('ambiguous') | |
| 217 | }) | |
| 218 | ||
| 219 | it('refuses when there is no cloud block at all', () => { | |
| 220 | expect(() => setAttachToInCloudConfig({ configText: 'export const tsCloud = {}\n', owner: 'stacks' })).toThrow('No `cloud:') | |
| 221 | }) | |
| 222 | }) | |
| @@ -0,0 +1,245 @@ | ||
| 1 | /** | |
| 2 | * Whether a project may safely attach to a server another project owns. | |
| 3 | * | |
| 4 | * `cloud.attachTo` already works: the attaching project's deploy puts its sites | |
| 5 | * on the owner's box instead of provisioning one. What has never existed is the | |
| 6 | * check BEFORE that deploy. | |
| 7 | * | |
| 8 | * Every way an attach goes wrong is currently discovered while it is going | |
| 9 | * wrong. `validateDeploymentConfig` only ever sees one project's `sites`, so a | |
| 10 | * second attach passes it; the port guard that would catch the clash runs from | |
| 11 | * inside the deploy, after the operator has committed a config change and | |
| 12 | * started shipping. And the worst case does not error at all: ts-cloud's units | |
| 13 | * do not set exclusive binding, so two services on one port both bind and the | |
| 14 | * kernel load-balances between them. Both look healthy, nothing is logged, and | |
| 15 | * each domain serves the other project's site about half the time - which is | |
| 16 | * what happened to predicthq.org for a day and a half. | |
| 17 | * | |
| 18 | * The checks here are the same ones the deploy makes, moved to before anything | |
| 19 | * is written or shipped, and answered from the box's own registry rather than | |
| 20 | * from any project's config. | |
| 21 | * | |
| 22 | * @see https://github.com/stacksjs/ts-cloud/issues/167 | |
| 23 | * @see https://github.com/stacksjs/ts-cloud/issues/168 | |
| 24 | * @see https://github.com/stacksjs/stacks/issues/2342 | |
| 25 | */ | |
| 26 | ||
| 27 | import type { DeclaredSite, HostedRoute, InventoryServer } from './inventory' | |
| 28 | import { occupiedHostPorts } from '../deploy/site-ports' | |
| 29 | ||
| 30 | /** The server an attach would target, or why one could not be picked. */ | |
| 31 | export type AttachTarget = | |
| 32 | | { server: InventoryServer } | |
| 33 | | { problem: string } | |
| 34 | ||
| 35 | /** | |
| 36 | * Pick the box named by the caller, by provider name or by owning project. | |
| 37 | * | |
| 38 | * Both spellings are accepted because both are what an operator has in front of | |
| 39 | * them: the provider console shows `stacks-production-app`, while `attachTo` | |
| 40 | * takes the owner's slug (`stacks`). Making them translate between the two is | |
| 41 | * how the wrong box gets named. An ambiguous match refuses rather than picking. | |
| 42 | */ | |
| 43 | export function resolveAttachTarget( | |
| 44 | servers: readonly InventoryServer[], | |
| 45 | wanted: string, | |
| 46 | environment?: string, | |
| 47 | ): AttachTarget { | |
| 48 | const target = wanted.trim() | |
| 49 | if (!target) return { problem: 'No server named.' } | |
| 50 | ||
| 51 | const [named, ...alsoNamed] = servers.filter(server => server.name === target) | |
| 52 | if (named && alsoNamed.length === 0) return { server: named } | |
| 53 | ||
| 54 | let byOwner = servers.filter(server => server.project === target) | |
| 55 | if (byOwner.length > 1 && environment) { | |
| 56 | byOwner = byOwner.filter(server => !server.environment || server.environment === environment) | |
| 57 | } | |
| 58 | ||
| 59 | const [owned, ...alsoOwned] = byOwner | |
| 60 | if (owned && alsoOwned.length === 0) return { server: owned } | |
| 61 | ||
| 62 | if (byOwner.length > 1) { | |
| 63 | return { | |
| 64 | problem: `'${target}' owns ${byOwner.length} servers (${byOwner.map(server => server.name).join(', ')}). ` | |
| 65 | + 'Name one of them, or narrow it by environment.', | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | return { | |
| 70 | problem: `No server matched '${target}'. Nothing is named that, and no box carries the label ` | |
| 71 | + `ts-cloud/project=${target}.`, | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Reasons an attach must not proceed at all, independent of what is on the box. | |
| 77 | * | |
| 78 | * Separate from conflicts because these are about identity rather than | |
| 79 | * occupancy: no amount of moving ports would make any of them safe. | |
| 80 | */ | |
| 81 | export function attachPreconditions(slug: string, server: InventoryServer): string[] { | |
| 82 | const problems: string[] = [] | |
| 83 | ||
| 84 | if (!server.project) { | |
| 85 | problems.push( | |
| 86 | `'${server.name}' carries no ts-cloud/project label, so it is not a box ts-cloud provisioned. ` | |
| 87 | + 'Attaching to it would deploy into a host nothing manages.', | |
| 88 | ) | |
| 89 | } | |
| 90 | else if (server.project === slug) { | |
| 91 | // The deploy refuses this too, but only once it is already running: a | |
| 92 | // tenant's deploy owns its own `<slug>.json` gateway fragment, so sharing a | |
| 93 | // slug with the owner means overwriting the owner's fragment and taking its | |
| 94 | // sites down. | |
| 95 | problems.push( | |
| 96 | `This project's slug is '${slug}', which is also the slug that owns '${server.name}'. ` | |
| 97 | + 'A tenant deploy owns the gateway fragment named after its slug, so attaching would ' | |
| 98 | + `overwrite '${server.project}'s own fragment and take its sites down. Change this project's slug first.`, | |
| 99 | ) | |
| 100 | } | |
| 101 | ||
| 102 | if (server.status !== 'running') { | |
| 103 | problems.push(`'${server.name}' is ${server.status}, so what it serves could not be read.`) | |
| 104 | } | |
| 105 | ||
| 106 | if (!server.ipv4) { | |
| 107 | problems.push(`'${server.name}' has no public IPv4 address, so it cannot be reached to check what it serves.`) | |
| 108 | } | |
| 109 | ||
| 110 | return problems | |
| 111 | } | |
| 112 | ||
| 113 | export interface AttachConflict { | |
| 114 | kind: 'port' | 'route' | |
| 115 | site: string | |
| 116 | detail: string | |
| 117 | /** The slug already holding it. */ | |
| 118 | heldBy: string | |
| 119 | } | |
| 120 | ||
| 121 | function routeKey(host: string, path: string): string { | |
| 122 | const normalized = path === '/' ? '/' : path.replace(/\/+$/, '') | |
| 123 | return `${host.toLowerCase()}${normalized || '/'}` | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Where this project's sites would land on top of another project's. | |
| 128 | * | |
| 129 | * Two independent collisions, and the port one is the dangerous half: a route | |
| 130 | * clash produces a visibly wrong page, while a port clash produces a working | |
| 131 | * box that serves the wrong site to about half its visitors with nothing | |
| 132 | * logged as an error. | |
| 133 | * | |
| 134 | * Ports come from {@link occupiedHostPorts} rather than from the routes | |
| 135 | * directly, so the collision check and the port allocator can never disagree | |
| 136 | * about which ports are taken. Our own slug is ignored on both axes: a | |
| 137 | * re-attach finds its own fragment on the box from the last deploy, and | |
| 138 | * counting it would make every repeat run conflict with itself. | |
| 139 | */ | |
| 140 | export function attachConflicts( | |
| 141 | slug: string, | |
| 142 | declared: readonly DeclaredSite[], | |
| 143 | routes: readonly HostedRoute[], | |
| 144 | ): AttachConflict[] { | |
| 145 | const conflicts: AttachConflict[] = [] | |
| 146 | ||
| 147 | const ports = occupiedHostPorts( | |
| 148 | routes | |
| 149 | .filter(route => route.kind === 'app') | |
| 150 | .map(route => ({ slug: route.slug, proxies: [{ from: route.target.split(',').map(upstream => upstream.trim()) }] })), | |
| 151 | { ignoreSlug: slug }, | |
| 152 | ) | |
| 153 | ||
| 154 | const taken = new Map<string, string>() | |
| 155 | for (const route of routes) { | |
| 156 | if (route.slug !== slug) taken.set(routeKey(route.host, route.path), route.slug) | |
| 157 | } | |
| 158 | ||
| 159 | for (const site of declared) { | |
| 160 | if (site.port !== undefined) { | |
| 161 | const holder = ports.get(site.port) | |
| 162 | if (holder) conflicts.push({ kind: 'port', site: site.name, detail: `port ${site.port}`, heldBy: holder }) | |
| 163 | } | |
| 164 | ||
| 165 | if (site.domain) { | |
| 166 | const holder = taken.get(routeKey(site.domain, site.path)) | |
| 167 | if (holder) { | |
| 168 | conflicts.push({ | |
| 169 | kind: 'route', | |
| 170 | site: site.name, | |
| 171 | detail: `${site.domain}${site.path === '/' ? '/' : site.path}`, | |
| 172 | heldBy: holder, | |
| 173 | }) | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | return conflicts | |
| 179 | } | |
| 180 | ||
| 181 | export interface AttachPlan { | |
| 182 | /** The attaching project. */ | |
| 183 | slug: string | |
| 184 | /** The project that owns the box. */ | |
| 185 | owner: string | |
| 186 | server: InventoryServer | |
| 187 | declared: readonly DeclaredSite[] | |
| 188 | conflicts: readonly AttachConflict[] | |
| 189 | /** Was the box's registry actually read? A check that saw nothing proves nothing. */ | |
| 190 | registryRead: boolean | |
| 191 | /** Why the registry could not be read, when it could not. */ | |
| 192 | registryProblem?: string | |
| 193 | } | |
| 194 | ||
| 195 | /** Is this attach safe to carry out? */ | |
| 196 | export function attachIsViable(plan: AttachPlan): boolean { | |
| 197 | return plan.registryRead && plan.conflicts.length === 0 | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * The attach as lines, ready to print. | |
| 202 | * | |
| 203 | * Deliberately does not describe the config edits an attach needs. Those live | |
| 204 | * in two different repositories - `attachTo` here, the tenant's slug in the | |
| 205 | * owner's `tenants` - so which of them a given caller can make is the caller's | |
| 206 | * business, and printing "these edits make it real" underneath a refusal reads | |
| 207 | * as though the operation is going ahead. | |
| 208 | */ | |
| 209 | export function formatAttachPlan(plan: AttachPlan): string[] { | |
| 210 | const { slug, owner, server, declared, conflicts } = plan | |
| 211 | const lines: string[] = [] | |
| 212 | ||
| 213 | lines.push(`Attach '${slug}' to '${server.name}' (${server.ipv4 ?? 'no IPv4'}), owned by '${owner}'.`, '') | |
| 214 | ||
| 215 | lines.push(` ${declared.length} site${declared.length === 1 ? '' : 's'} would deploy onto this box:`) | |
| 216 | for (const site of declared) { | |
| 217 | const where = site.loopbackOnly | |
| 218 | ? `loopback only${site.port ? ` on :${site.port}` : ''}` | |
| 219 | : `${site.domain}${site.path === '/' ? '/' : site.path}${site.port ? ` on :${site.port}` : ''}` | |
| 220 | lines.push(` ${site.name} ${where} -> ${site.installBase ?? '(install path unresolved)'}`) | |
| 221 | } | |
| 222 | lines.push('') | |
| 223 | ||
| 224 | if (!plan.registryRead) { | |
| 225 | // Saying "no conflicts" here would be a claim about a box nobody asked. | |
| 226 | lines.push(` Could not read what '${server.name}' already serves: ${plan.registryProblem ?? 'unknown reason'}`) | |
| 227 | lines.push(' So this attach is UNCHECKED: a port or hostname already taken by another') | |
| 228 | lines.push(' project would not error, it would serve that project\'s site from your domain.') | |
| 229 | } | |
| 230 | else if (conflicts.length > 0) { | |
| 231 | lines.push(` ${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} with what the box already serves:`) | |
| 232 | for (const conflict of conflicts) { | |
| 233 | lines.push(` site '${conflict.site}' wants ${conflict.detail}, held by '${conflict.heldBy}'`) | |
| 234 | } | |
| 235 | lines.push('') | |
| 236 | lines.push(' Two services on one port do not error: the kernel load-balances, and each') | |
| 237 | lines.push(' domain serves the other\'s site about half the time. Pick free ports and') | |
| 238 | lines.push(' hostnames, then re-run.') | |
| 239 | } | |
| 240 | else { | |
| 241 | lines.push(` No conflicts with what '${server.name}' already serves.`) | |
| 242 | } | |
| 243 | ||
| 244 | return lines | |
| 245 | } | |