ReviewOS

also looking at this

stacks/ts-cloud

feat(operations): fleet inventory, attach preflight, and the exports that were missing

#192
Merged glennmichael123 wants to merge feat/fleet-inventory-and-attach-preflight into main
11 files +1313 -5
packages/ts-cloud/build.tsmodified+3-2
Changes to packages/ts-cloud/build.ts
@@ -5,8 +5,8 @@ const __dirname = import.meta.dirname
55async function build() {
66 // Build the library root AND every subpath entry point declared in the
77 // 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
1010 // in dist, so `import '@stacksjs/ts-cloud/drivers'` fails at runtime for
1111 // consumers. Splitting keeps shared code in chunks instead of duplicating
1212 // it into each subpath bundle.
@@ -17,6 +17,7 @@ async function build() {
1717 join(__dirname, 'src/deploy/index.ts'),
1818 join(__dirname, 'src/dns/index.ts'),
1919 join(__dirname, 'src/drivers/index.ts'),
20 join(__dirname, 'src/operations/index.ts'),
2021 join(__dirname, 'src/push/index.ts'),
2122 join(__dirname, 'src/spend/index.ts'),
2223 join(__dirname, 'src/protection/index.ts'),
packages/ts-cloud/package.jsonmodified+4-0
Changes to packages/ts-cloud/package.json
@@ -46,6 +46,10 @@
4646 "types": "./dist/drivers/index.d.ts",
4747 "import": "./dist/drivers/index.js"
4848 },
49 "./operations": {
50 "types": "./dist/operations/index.d.ts",
51 "import": "./dist/operations/index.js"
52 },
4953 "./push": {
5054 "types": "./dist/push/index.d.ts",
5155 "import": "./dist/push/index.js"
packages/ts-cloud/src/deploy/index.tsmodified+6-0
Changes to packages/ts-cloud/src/deploy/index.ts
@@ -4,6 +4,12 @@
44 */
55
66export * 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.
11export * from './site-config-editor'
12export * from './site-ports'
713export * from './server-dns'
814export * from './dashboard-control-plane'
915export * from './dashboard-route-manifest'
packages/ts-cloud/src/deploy/site-config-editor.tsmodified+63-0
Changes to packages/ts-cloud/src/deploy/site-config-editor.ts
@@ -358,3 +358,66 @@ function findMatchingBrace(text: string, start: number): number {
358358
359359 throw new Error('Could not find the closing brace for sites: { ... } in cloud.config.ts')
360360}
361
362export 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 */
388export 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}
packages/ts-cloud/src/deploy/site-ports.tsmodified+19-3
Changes to packages/ts-cloud/src/deploy/site-ports.ts
@@ -69,12 +69,28 @@ export const DEFAULT_SITE_PORT_RANGE: SitePortRange = { start: 3000, end: 3999 }
6969 * One project's registry fragment, as written by the deploy:
7070 * `JSON.stringify({ slug, ...RpxGatewayConfig })`.
7171 *
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`.
7479 */
7580export interface HostSiteFragment {
7681 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 }>
7894}
7995
8096/** Port -> the slug of the project that already serves it on this box. */
packages/ts-cloud/src/index.tsmodified+16-0
Changes to packages/ts-cloud/src/index.ts
@@ -1,4 +1,5 @@
11export * from './config'
2export * from './operations'
23export * from './auth'
34export * from './automation'
45export * from './api'
@@ -260,7 +261,22 @@ export {
260261 resolveSiteDeployTarget,
261262 resolveSiteKind,
262263 shipsARelease,
264 siteInstallBase,
263265 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,
264280 // Serverless application pipeline (Laravel-Vapor-equivalent)
265281 buildAndPushServerlessImage,
266282 buildFunctionEnv,
packages/ts-cloud/src/operations/index.tsadded+25-0
Changes to packages/ts-cloud/src/operations/index.ts
@@ -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
20export * from './drained-sites'
21export * from './inventory'
22export * from './plan'
23export * from './server-rename'
24export * from './site-attach'
25export * from './site-move'
packages/ts-cloud/src/operations/inventory.test.tsadded+280-0
Changes to packages/ts-cloud/src/operations/inventory.test.ts
@@ -0,0 +1,280 @@
1import { describe, expect, it } from 'bun:test'
2import { buildHostSitePortsScript, parseHostSiteFragments } from '../deploy/site-ports'
3import {
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
21const 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
30const RAPPID_FRAGMENT = {
31 slug: 'rappid',
32 proxies: [{ to: 'rappid.hq.training', from: '127.0.0.1:3024' }],
33}
34
35function site(name: string, overrides: Record<string, any> = {}) {
36 return { name, path: '/', loopbackOnly: overrides.domain === undefined, ...overrides }
37}
38
39describe('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
74describe('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
119describe('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
159describe('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
203describe('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})
packages/ts-cloud/src/operations/inventory.tsadded+430-0
Changes to packages/ts-cloud/src/operations/inventory.ts
@@ -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
27import type { HostSiteFragment } from '../deploy/site-ports'
28import { buildHostSitePortsScript, HOST_SITES_DIR, parseHostSiteFragments } from '../deploy/site-ports'
29
30/** The ts-cloud labels every provisioned box carries. */
31export const PROJECT_LABEL = 'ts-cloud/project'
32export const ENVIRONMENT_LABEL = 'ts-cloud/environment'
33export 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 */
42export 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. */
60export 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. */
70export 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
78function 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 */
93export 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. */
115function 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 */
123export 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
141function 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. */
159export 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. */
173export 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 */
183export 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. */
212export 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. */
229export 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
240function 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 */
253export 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 */
281export 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
296export 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 */
312export 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
347function 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
356function 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
375function 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
381function 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}
packages/ts-cloud/src/operations/site-attach.test.tsadded+222-0
Changes to packages/ts-cloud/src/operations/site-attach.test.ts
@@ -0,0 +1,222 @@
1import { describe, expect, it } from 'bun:test'
2import { setAttachToInCloudConfig } from '../deploy/site-config-editor'
3import { routesFromFragments, toInventoryServer } from './inventory'
4import { 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
15function 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
26function site(name: string, overrides: Record<string, any> = {}) {
27 return { name, path: '/', loopbackOnly: overrides.domain === undefined, ...overrides }
28}
29
30const 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
41describe('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
69describe('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
91describe('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
123describe('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
161describe('writing the attach into a cloud config', () => {
162 const CONFIG = `import { env } from '@stacksjs/env'
163
164export 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})
packages/ts-cloud/src/operations/site-attach.tsadded+245-0
Changes to packages/ts-cloud/src/operations/site-attach.ts
@@ -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
27import type { DeclaredSite, HostedRoute, InventoryServer } from './inventory'
28import { occupiedHostPorts } from '../deploy/site-ports'
29
30/** The server an attach would target, or why one could not be picked. */
31export 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 */
43export 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 */
81export 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
113export interface AttachConflict {
114 kind: 'port' | 'route'
115 site: string
116 detail: string
117 /** The slug already holding it. */
118 heldBy: string
119}
120
121function 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 */
140export 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
181export 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? */
196export 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 */
209export 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}