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