also looking at this
feat(operations): fleet inventory, attach preflight, and the exports that were missing
#192
11 files
+1313
-5
| @@ -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 | } | |