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.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})