also looking at this
test(fleet): prove the database and hostname halves of a transfer on real machines
#184
4 files
+363
-3
| @@ -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 | ||
| 18 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 19 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 20 | import { tmpdir } from 'node:os' | |
| 21 | import { join } from 'node:path' | |
| 22 | import { buildSetHostnameScript } from '../../src/operations/server-rename' | |
| 23 | ||
| 24 | const PLAIN = 'ts-cloud-rename-plain' | |
| 25 | const IMAGE = 'ts-cloud-rename-box:22.04' | |
| 26 | const NEW_NAME = 'hq-production-server' | |
| 27 | ||
| 28 | async function available(command: string[]): Promise<boolean> { | |
| 29 | const result = await Bun.$`${command}`.quiet().nothrow() | |
| 30 | return result.exitCode === 0 | |
| 31 | } | |
| 32 | ||
| 33 | const canRun = await available(['docker', 'info']) | |
| 34 | ||
| 35 | async 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 | ||
| 49 | async 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 | ||
| 55 | describe.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 | }) | |