ReviewOS

also looking at this

stacks/ts-cloud

test(fleet): prove the database and hostname halves of a transfer on real machines

#184
Merged glennmichael123 wants to merge test/move-database-and-rename-e2e into main
4 files +363 -3
packages/ts-cloud/test/integration/server-rename-e2e.test.tsadded+126-0
Changes to packages/ts-cloud/test/integration/server-rename-e2e.test.ts
@@ -0,0 +1,126 @@
1/**
2 * End-to-end check of the one part of `server:rename` that touches a machine.
3 *
4 * Three of the four records a rename updates are data — a provider API call, a
5 * JSON state file, a row in the inventory — and unit tests cover them properly.
6 * The fourth is shell run on the box, and it is the one with somewhere to go
7 * wrong: `hostnamectl` is not always available, `/etc/hosts` may or may not
8 * already carry a `127.0.1.1` line, and getting either wrong leaves a box whose
9 * own name does not resolve — which surfaces later as `sudo` hanging, not as a
10 * failed rename.
11 *
12 * Both paths are exercised: with systemd present (`hostnamectl`) and without it
13 * (the `/etc/hostname` fallback the script falls back to).
14 *
15 * @see https://github.com/stacksjs/ts-cloud/issues/167
16 */
17
18import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
19import { mkdtemp, rm, writeFile } from 'node:fs/promises'
20import { tmpdir } from 'node:os'
21import { join } from 'node:path'
22import { buildSetHostnameScript } from '../../src/operations/server-rename'
23
24const PLAIN = 'ts-cloud-rename-plain'
25const IMAGE = 'ts-cloud-rename-box:22.04'
26const NEW_NAME = 'hq-production-server'
27
28async function available(command: string[]): Promise<boolean> {
29 const result = await Bun.$`${command}`.quiet().nothrow()
30 return result.exitCode === 0
31}
32
33const canRun = await available(['docker', 'info'])
34
35async function exec(box: string, script: string): Promise<{ stdout: string, code: number }> {
36 const child = Bun.spawn(['docker', 'exec', '-i', box, 'bash', '-s'], {
37 stdin: new Blob([script]),
38 stdout: 'pipe',
39 stderr: 'pipe',
40 })
41 const [code, stdout, stderr] = await Promise.all([
42 child.exited,
43 new Response(child.stdout).text(),
44 new Response(child.stderr).text(),
45 ])
46 return { stdout: stdout + stderr, code }
47}
48
49async function run(box: string, script: string): Promise<string> {
50 const result = await exec(box, script)
51 if (result.code !== 0) throw new Error(`${box} exited ${result.code}:\n${result.stdout}`)
52 return result.stdout
53}
54
55describe.skipIf(!canRun)('server:rename on a box (docker)', () => {
56 let workspace: string
57
58 beforeAll(async () => {
59 workspace = await mkdtemp(join(tmpdir(), 'ts-cloud-rename-e2e-'))
60 const dockerfile = join(workspace, 'Dockerfile')
61 await writeFile(
62 dockerfile,
63 ['FROM ubuntu:22.04', 'ENV DEBIAN_FRONTEND=noninteractive', 'RUN apt-get update && apt-get install -y hostname && apt-get clean'].join('\n'),
64 )
65 await Bun.$`docker build -q -f ${dockerfile} -t ${IMAGE} ${workspace}`.quiet()
66 await Bun.$`docker rm -f ${PLAIN}`.quiet().nothrow()
67 // No systemd, so `hostnamectl` is absent and the fallback has to carry it —
68 // the path a minimal or containerised box actually takes.
69 // CAP_SYS_ADMIN because setting a hostname needs it and containers drop it by
70 // default — a real box's root has it, so without this the container would be
71 // testing a restriction the target environment does not have.
72 await Bun.$`docker run -d --name ${PLAIN} --hostname bughq --cap-add SYS_ADMIN ${IMAGE} sleep infinity`.quiet()
73 }, 600_000)
74
75 afterAll(async () => {
76 await Bun.$`docker rm -f ${PLAIN}`.quiet().nothrow()
77 await rm(workspace, { recursive: true, force: true })
78 })
79
80 it('starts from the old name', async () => {
81 expect((await run(PLAIN, 'hostname')).trim()).toBe('bughq')
82 })
83
84 it('renames the box when hostnamectl is unavailable', async () => {
85 const out = await run(PLAIN, buildSetHostnameScript(NEW_NAME))
86 expect(out).toContain(`bughq -> ${NEW_NAME}`)
87 expect((await run(PLAIN, 'hostname')).trim()).toBe(NEW_NAME)
88 expect((await run(PLAIN, 'cat /etc/hostname')).trim()).toBe(NEW_NAME)
89 })
90
91 /**
92 * Two `127.0.1.1` lines would leave the box resolving its own name two ways,
93 * which is the failure mode this replaces-in-place rather than appends for.
94 */
95 it('leaves exactly one 127.0.1.1 entry, pointing at the new name', async () => {
96 const hosts = await run(PLAIN, 'cat /etc/hosts')
97 const lines = hosts.split('\n').filter(line => line.trim().startsWith('127.0.1.1'))
98 expect(lines).toHaveLength(1)
99 expect(lines[0]).toContain(NEW_NAME)
100 expect(lines[0]).not.toContain('bughq')
101 })
102
103 it('resolves its own new name', async () => {
104 expect((await exec(PLAIN, `getent hosts ${NEW_NAME}`)).code).toBe(0)
105 })
106
107 /** Re-running a finished rename is what a resumed operation does. */
108 it('is idempotent, and still leaves one entry', async () => {
109 await run(PLAIN, buildSetHostnameScript(NEW_NAME))
110 expect((await run(PLAIN, 'hostname')).trim()).toBe(NEW_NAME)
111 const lines = (await run(PLAIN, 'cat /etc/hosts')).split('\n').filter(l => l.trim().startsWith('127.0.1.1'))
112 expect(lines).toHaveLength(1)
113 })
114
115 it('adds the entry on a box that had none', async () => {
116 // In place, for the same reason the product script is: /etc/hosts is a bind
117 // mount in a container and cannot be renamed over.
118 await run(PLAIN, 'TS_CLOUD_H="$(grep -v "^127.0.1.1" /etc/hosts)"; printf \'%s\\n\' "$TS_CLOUD_H" > /etc/hosts')
119 expect((await run(PLAIN, 'cat /etc/hosts')).split('\n').filter(l => l.trim().startsWith('127.0.1.1'))).toHaveLength(0)
120
121 await run(PLAIN, buildSetHostnameScript('hq-second-name'))
122 const lines = (await run(PLAIN, 'cat /etc/hosts')).split('\n').filter(l => l.trim().startsWith('127.0.1.1'))
123 expect(lines).toHaveLength(1)
124 expect(lines[0]).toContain('hq-second-name')
125 })
126})