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/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}