kohupdate.test.ts

8.4 KB · raw

1import { afterAll, beforeAll, describe, expect, test } from "bun:test";2import { cp, mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import type { Server } from "bun";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import { createApp } from "../src/server/app.ts";9import { openKeprDb } from "../src/server/db.ts";10import { kohWorkspace } from "./fixture.ts";1112/**13 * The joint update contract (§3 item 8): koh3 publishes a newer koh into a folder remote, Kepr14 * syncs and serves it, and a second koh3 — a copy of the installed binary, never the installed one15 * — replaces itself with what it downloads.16 *17 * The release file is a real copy of the koh3 binary, so the ad-hoc codesign on macOS is the real18 * thing. One environment limit is documented below: Kepr builds its download URLs as `https://…`19 * by design, and this test's server has no TLS, so the document it serves for the install points20 * the download at the `file://` path a folder-bucket Kepr hands out (which koh3 reads).21 */2223const NEW_VERSION = "9.9.9";24const REPO = "koh";25const NOTES =26	"the updater replaces itself, end to end, through a Kepr that serves the release it was told about";2728/** What koh3 reads: the release document (§22.4). */29type Feed = {30	version: string;31	notes?: string;32	platforms: Record<string, { url: string; blake3: string; sha256: string }>;33};3435/** The record koh3 writes, as far as this test reads it (§11.2). */36type ReleaseRecord = { files: { platform: string; name: string }[] };3738/** The CLI's own `--json` document, `UpdateJson` in koh3's src/cli/cmd_update.v. */39type UpdateJson = { available?: boolean; installed?: string };4041describe("koh3 updating itself through Kepr", () => {42	let server: Server<undefined>;43	let checkSource = "";44	let installSource = "";45	let installed = "";46	let published = "";47	let checks: {48		code: number;49		json: UpdateJson;50		install: UpdateJson;51		refused: string;52		refusedStdout: string;53		baseline: Uint8Array;54		kept: Uint8Array;55		target: string;56	};5758	beforeAll(async () => {59		const space = await kohWorkspace(REPO);60		const koh = Bun.which("koh3") as string;61		const platformName = platform();62		installed = join(space.root, "bin", "koh3");63		published = join(space.repoDir, "dist", "koh3");64		await mkdir(join(space.root, "bin"), { recursive: true });65		await mkdir(join(space.repoDir, "dist"), { recursive: true });66		await cp(koh, installed);67		await cp(koh, published);68		const baseline = new Uint8Array(await Bun.file(installed).arrayBuffer());6970		await writeFile(join(space.repoDir, "VERSION"), `${NEW_VERSION}\n`);71		await writeFile(72			join(space.repoDir, "koh.build"),73			[74				"# a release is one file: this copies the one the test prepared",75				"command = mkdir -p out && cp dist/koh3 out/koh3",76				"output = out/koh3",77				`platforms = ${platformName}`,78				"",79			].join("\n"),80		);81		space.run("init");82		space.run("remote", "folder", space.bucketDir);83		space.run("save", "the new koh");84		space.run("offer", "--builds", "--notes", NOTES);8586		const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-"));87		const kepr = openKeprDb(dataDir);88		await syncAll({89			bucket: openBucket({ kind: "folder", path: space.bucketDir }),90			kepr,91			mirrorDir: join(dataDir, "mirror"),92			tarballDir: join(dataDir, "tarballs"),93		});94		kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");9596		// A download URL in the feed carries the configured domain, so the app is built97		// with the real one once the port is known.98		const config = (domain: string) => ({99			domain,100			dataDir,101			adminPasswordHash: "$argon2id$x",102			bucket: { kind: "folder" as const, path: space.bucketDir },103		});104		let app = createApp(config("127.0.0.1:1"), kepr);105		let local: Feed | null = null;106		const localPath = `/${REPO}/releases/latest.local.json`;107		server = Bun.serve({108			port: 0,109			fetch: (request) => {110				const url = new URL(request.url);111				if (url.pathname === localPath && local) return Response.json(local);112				return app.fetch(request);113			},114		});115		app = createApp(config(`127.0.0.1:${server.port}`), kepr);116		checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;117		installSource = `http://127.0.0.1:${server.port}${localPath}`;118119		// The document Kepr served, with its download pointed at the very file the120		// folder bucket holds (the TLS gap above).121		const served: Feed = (await (await fetch(checkSource)).json()) as Feed;122		const record: ReleaseRecord = (await Bun.file(123			join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),124		).json()) as ReleaseRecord;125		const artifact = record.files.find((file) => file.platform === platformName);126		if (!artifact) throw new Error("the published record has no file for this platform");127		const artifactPath = join(128			space.bucketDir,129			REPO,130			"releases",131			NEW_VERSION,132			platformName,133			artifact.name,134		);135		local = {136			...served,137			platforms: {138				...served.platforms,139				[platformName]: { ...served.platforms[platformName], url: `file://${artifactPath}` },140			},141		};142143		const check = await run(installed, space.home, checkSource, "update", "--check");144		const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");145		const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");146		const kept = new Uint8Array(await Bun.file(installed).arrayBuffer());147		const install = await run(installed, space.home, installSource, "update", "--yes", "--json");148		checks = {149			code: check.exitCode,150			json: JSON.parse(checkJson.stdout) as UpdateJson,151			install: JSON.parse(install.stdout) as UpdateJson,152			refused: refused.stderr,153			refusedStdout: refused.stdout,154			baseline,155			kept,156			// koh names the binary it replaced by its real path, and /var is a symlink.157			target: await realpath(installed),158		};159	});160161	afterAll(() => {162		server?.stop(true);163	});164165	/**166	 * Runs a koh3 with the update source pointed at this test's Kepr. Asynchronous on purpose:167	 * `Bun.spawnSync` would block the event loop this test's own HTTP server runs on, and the two168	 * would wait for each other forever.169	 */170	async function run(path: string, home: string, source: string, ...args: string[]) {171		const proc = Bun.spawn([path, ...args], {172			env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source },173			stdout: "pipe",174			stderr: "pipe",175		});176		const [stdout, stderr, exitCode] = await Promise.all([177			new Response(proc.stdout).text(),178			new Response(proc.stderr).text(),179			proc.exited,180		]);181		return { stdout, stderr, exitCode };182	}183184	test("--check reports an update and exits 1, and --json says the same thing", () => {185		expect(checks.code).toBe(1);186		expect(checks.json.available).toBe(true);187	});188189	test("a download it cannot reach is refused, and the binary is left alone", () => {190		// Kepr's own URL is https on a server that has no TLS, so this is what an191		// unreachable release file looks like: a refusal, never a half-install.192		expect(checks.refused).toContain("could not download");193		expect(checks.refusedStdout).toBe("");194		expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);195	});196197	test("the update replaced the running binary with the published file", async () => {198		expect(checks.install.installed).toBe(checks.target);199		const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());200		const released = new Uint8Array(await Bun.file(published).arrayBuffer());201		if (process.platform === "darwin") {202			// Apple silicon refuses to run an unsigned binary, and the ad-hoc signature203			// koh writes is part of the file, so the installed copy is the artifact plus204			// that signature. Being validly signed and running is the proof here; the205			// Blake3 of the bytes that arrived is checked inside koh before signing.206			const signed = Bun.spawn(["codesign", "--verify", installed]);207			expect(await signed.exited).toBe(0);208		} else {209			expect(Buffer.compare(downloaded, released)).toBe(0);210		}211		// And it is a working koh still: the replaced binary runs.212		const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();213		expect(version.startsWith("koh ")).toBe(true);214	});215});216217/** This machine's platform, which is what `koh build` uses with no flags. */218function platform(): string {219	const os = process.platform === "darwin" ? "macos" : "linux";220	const arch = process.arch === "arm64" ? "aarch64" : "x86_64";221	return `${os}-${arch}`;222}