import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const SCRIPT = join(import.meta.dirname, "..", "..", "deploy", "update.sh"); const FAKE = join(import.meta.dirname, "fake-compose-docker.mjs"); const REPO = "ghcr.io/milind-soni/openmausbot"; const posix = process.platform !== "win32"; const id = (letter: string) => `sha256:${letter.repeat(64)}`; const OLD = { id: id("a"), refs: [`${REPO}:latest`, `${REPO}@${id("1")}`], version: "0.1.150", local: true, engine: true }; const NEW = { id: id("b"), refs: [`${REPO}@${id("2")}`, `${REPO}:v0.1.151`], version: "0.1.151", local: false, engine: true }; const ENV = "DOMAIN=maus.example.test\nOMB_SIGNIN_EMAILS=owner@example.test\n"; /** .env after a rollback: as it was, with the version that ran before pinned by its digest, so a tag that moved since * (the installer's `stable`) cannot bring the undone version back on a later run of the installer. */ const ROLLED_BACK = `${ENV}OMB_IMAGE=${OLD.refs[1]}\n`; const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); /** Every file under `dir` with its content: what "the data as it was" means. */ function tree(dir: string, prefix = ""): Record { const files: Record = {}; for (const name of readdirSync(dir).sort()) { const path = join(dir, name); if (statSync(path).isDirectory()) Object.assign(files, tree(path, `${prefix}${name}/`)); else files[`${prefix}${name}`] = readFileSync(path, "utf8"); } return files; } /** A stable.json as promote-stable.yml writes it: the newest entry first, then every version stable before it. */ function stableRecord(version: string, image: string, earlier: string[] = []) { const history = [version, ...earlier].map((entry, at) => ({ version: entry, promotedAt: `2026-11-${String(10 - at).padStart(2, "0")}T00:00:00.000Z` })); return JSON.stringify({ channel: "stable", version, image, cloudHomeImage: `${REPO}-cloud-home@${id("9")}`, platforms: ["linux/amd64", "linux/arm64"], promotedAt: history[0]!.promotedAt, history }, null, 2); } /** A Compose install on version 0.1.150 with some data, and a stable channel that names `target`. */ function install(target: Record = {}, extra: Record = {}) { const root = mkdtempSync(join(tmpdir(), "omb-update-")); dirs.push(root); const deploy = join(root, "deploy"); const fake = join(root, "fake"); const data = join(fake, "volumes", "deploy_data"); mkdirSync(join(root, "bin")); mkdirSync(deploy); mkdirSync(join(data, ".openmausbot"), { recursive: true }); writeFileSync(join(data, ".openmausbot", "bots.json"), '[{"name":"Mira"}]'); writeFileSync(join(data, "started-by.txt"), "0.1.150\n"); copyFileSync(SCRIPT, join(deploy, "update.sh")); writeFileSync(join(deploy, "docker-compose.yml"), "services: {}\n"); writeFileSync(join(deploy, ".env"), ENV); writeFileSync(join(root, "bin", "docker"), `#!/bin/sh\nexec "${process.execPath}" "${FAKE}" "$@"\n`); chmodSync(join(root, "bin", "docker"), 0o755); const next = { ...NEW, ...target }; // A version that never answers is waited for; two seconds of that is enough here. const wait = next.behaviour === "silent" ? { OMB_UPDATE_WAIT_S: "2" } : {}; writeFileSync(join(fake, "state.json"), JSON.stringify({ serial: 1, composeReadsPin: true, defaultImage: `${REPO}:latest`, dataVolume: "deploy_data", images: [OLD, next], containers: { omb: { id: "omb-1", image: OLD.id, status: "running", restarts: 0 }, caddy: { id: "caddy-1", status: "running" } }, ...extra, })); writeFileSync(join(root, "stable.json"), stableRecord(next.version, next.refs[0]!)); const state = () => JSON.parse(readFileSync(join(fake, "state.json"), "utf8")); const run = (args: string[] = [], options: { input?: string; env?: Record } = {}) => { const result = spawnSync("bash", [join(deploy, "update.sh"), ...args], { encoding: "utf8", input: options.input ?? "", timeout: 60_000, env: { // sbin: sha256sum is /sbin/sha256sum on macOS. PATH: `${join(root, "bin")}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: root, FAKE_DOCKER_DIR: fake, OMB_STABLE_URL: `file://${join(root, "stable.json")}`, OMB_NEWEST_URL: `file://${join(root, "newest.json")}`, OMB_UPDATE_POLL_S: "0", OMB_UPDATE_WAIT_S: "120", OMB_UPDATE_ENGINE_WAIT_S: "1", ...wait, ...options.env, }, }); return { ...result, said: result.stdout.trim().split("\n") }; }; const calls = () => (existsSync(join(fake, "calls.log")) ? readFileSync(join(fake, "calls.log"), "utf8").trim().split("\n") : []); /** The copies of the data an update made, oldest first: one Docker volume each. */ const copies = () => readdirSync(join(fake, "volumes")).filter((name) => name.startsWith("deploy_data-before-update-")).sort(); return { root, deploy, fake, data, state, run, calls, copies, setState: (patch: Record) => writeFileSync(join(fake, "state.json"), JSON.stringify({ ...state(), ...patch })), env: () => readFileSync(join(deploy, ".env"), "utf8"), backup: () => { const [only, ...more] = copies(); if (!only || more.length) throw new Error(`expected one copy of the data, found: ${copies().join(", ")}`); return join(fake, "volumes", only); }, journal: () => existsSync(join(deploy, ".update", "journal")), last: () => existsSync(join(deploy, ".update", "last")), }; } describe.skipIf(!posix)("deploy/update.sh", () => { it("is valid bash", () => { expect(spawnSync("bash", ["-n", SCRIPT]).status).toBe(0); }); it("copies the data aside, pins the stable image by digest, and waits until the new version answers and can start bots", () => { const box = install(); const before = tree(box.data); const done = box.run(); expect(done.status, done.stdout + done.stderr).toBe(0); expect(done.said).toEqual([ "Downloading 0.1.151…", "Stopping the server and copying its data aside…", "Starting 0.1.151…", "Waiting for it to answer and to be able to start bots…", "Updated from 0.1.150 to 0.1.151.", `The data from before the update is kept in the Docker volume ${box.copies()[0]} until the next update.`, "To go back to it: ./update.sh --rollback", ]); expect(box.copies()).toEqual([expect.stringMatching(/^deploy_data-before-update-\d{8}T\d{6}Z$/)]); expect(box.env()).toBe(`${ENV}OMB_IMAGE=${NEW.refs[0]}\n`); expect(box.state().containers.omb).toMatchObject({ image: NEW.id, status: "running" }); expect(tree(box.backup())).toEqual(before); expect(tree(box.data)["started-by.txt"]).toBe("0.1.150\n0.1.151\n"); expect(box.journal()).toBe(false); const calls = box.calls(); const order = [ calls.findIndex((call) => call.startsWith("pull")), calls.indexOf("compose stop"), calls.findIndex((call) => call.includes("--entrypoint cp")), calls.findIndex((call) => call.includes("same /from /to")), calls.findIndex((call) => call.startsWith("compose up")), calls.findLastIndex((call) => call.includes("/api/health")), ]; expect(order.every((at) => at >= 0)).toBe(true); expect(order).toEqual([...order].sort((a, b) => a - b)); expect(calls.find((call) => call.includes("--entrypoint cp"))).toBe( `run --rm --network none --user 0:0 --label com.mausbot.role=update-helper -v deploy_data:/from:ro -v ${box.copies()[0]}:/to --entrypoint cp ${OLD.id} -a /from/. /to/`, ); // The server was stopped, and seen to be stopped, before the copy. expect(calls.slice(calls.indexOf("compose stop"), calls.findIndex((call) => call.includes("--entrypoint cp"))).some((call) => call.includes("{{.State.Running}}"))).toBe(true); }); it("changes nothing when it is run again", () => { const box = install(); expect(box.run().status).toBe(0); const calls = box.calls().length; const again = box.run(); expect(again.status).toBe(0); expect(again.said).toEqual(["Downloading 0.1.151…", "This server already runs 0.1.151. Nothing to do."]); expect(box.calls().slice(calls).some((call) => /^(compose (stop|up)|run|volume (create|rm))/.test(call))).toBe(false); expect(box.env()).toBe(`${ENV}OMB_IMAGE=${NEW.refs[0]}\n`); }); it("puts back the old image, its settings and the data when the new version never answers", () => { const box = install({ behaviour: "silent" }); const before = tree(box.data); const failed = box.run(); expect(failed.status).toBe(1); expect(failed.said.slice(-3)).toEqual([ "The update to 0.1.151 did not work: it did not answer within 2 seconds.", "Putting back the version and the data from before the update…", "Your server runs 0.1.150 again, with its data as it was before the update.", ]); expect(box.env()).toBe(ROLLED_BACK); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); expect(box.state().containers.omb).toMatchObject({ image: OLD.id, status: "running" }); expect(box.state().containers.caddy.status).toBe("running"); expect(box.journal()).toBe(false); }); it("says why in the server's own last words when the new version stops as it starts", () => { const box = install({ behaviour: "crashes", lastWords: "This data was written by a newer MausBot." }); const failed = box.run(); expect(failed.status).toBe(1); expect(failed.stdout).toContain("The update to 0.1.151 did not work: it kept stopping as it started (This data was written by a newer MausBot.)."); expect(failed.said.at(-1)).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(tree(box.data)["started-by.txt"]).toBe("0.1.150\n0.1.150\n"); }); it.each([ [{ engine: false }, "no engine can start a bot on it, and one could before"], [{ behaviour: "no-caddy" }, "Caddy, which serves it to the internet, did not start again"], [{ serves: "0.1.149" }, "it says it is version 0.1.149, not 0.1.151"], ])("rolls back a version that answers but lost what worked before: %j", (target, why) => { const box = install(target); const failed = box.run(); expect(failed.status, failed.stdout).toBe(1); expect(failed.stdout).toContain(`The update to 0.1.151 did not work: ${why}.`); expect(box.state().containers.omb.image).toBe(OLD.id); expect(box.env()).toBe(ROLLED_BACK); }); it("does not hold a missing engine against the new version when none worked before", () => { const box = install({ engine: false }); box.setState({ images: [{ ...OLD, engine: false }, { ...NEW, engine: false }] }); expect(box.run().status).toBe(0); }); it("stops before touching the server when the image cannot be downloaded, the disk is short, or the Compose file is an old one", () => { for (const [extra, words] of [ [{ failPull: true }, `Could not download ${NEW.refs[0]}`], [{ dataKb: 4_000_000, freeKb: 3_000_000 }, "There is not enough free disk for a copy of the data: it needs about 5320 MB and 2929 MB are free."], [{ composeReadsPin: false }, "does not take its image from OMB_IMAGE"], ] as const) { const box = install({}, extra); const refused = box.run(); expect(refused.status, words).toBe(4); expect(refused.stdout).toContain(words); expect(refused.stdout).toContain("Nothing was changed."); expect(box.calls().some((call) => /^compose (stop|up)/.test(call))).toBe(false); expect(box.env()).toBe(ENV); expect(box.journal()).toBe(false); } }); it("starts the server again as it was when the copy of the data fails", () => { const box = install({}, { failCopy: true }); const before = tree(box.data); const refused = box.run(); expect(refused.status).toBe(4); expect(refused.said.at(-1)).toMatch(/^Copying the data aside failed, .* Nothing was changed, and the server was started again\.$/); expect(box.state().containers.omb).toMatchObject({ image: OLD.id, status: "running" }); expect(box.copies()).toEqual([]); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); }); it("refuses a second update while one runs, an image built on the machine, and a channel record that names no pinned image", () => { const busy = install(); mkdirSync(join(busy.deploy, ".update", "lock"), { recursive: true }); writeFileSync(join(busy.deploy, ".update", "lock", "pid"), String(process.pid)); const second = busy.run(); expect(second.status).toBe(3); expect(second.stdout).toContain(`An update is already running here (process ${process.pid}).`); expect(existsSync(join(busy.deploy, ".update", "lock"))).toBe(true); const built = install(); built.setState({ images: [{ ...OLD, refs: [`${REPO}:latest`] }, NEW] }); const refused = built.run(); expect(refused.status).toBe(4); expect(refused.stdout).toContain("runs an image built on this machine"); expect(built.run(["--replace-local-build"]).status).toBe(0); const unpinned = install(); writeFileSync(join(unpinned.root, "stable.json"), JSON.stringify({ version: "0.1.151", image: `${REPO}:latest` })); expect(unpinned.run().stdout).toContain("The stable channel names no image this script accepts. Nothing was changed."); }); it("takes over a lock whose update is gone", () => { const box = install(); mkdirSync(join(box.deploy, ".update", "lock"), { recursive: true }); writeFileSync(join(box.deploy, ".update", "lock", "pid"), "999999"); const done = box.run(); expect(done.status, done.stdout + done.stderr).toBe(0); }); // As the real-server check's step 6 runs it: killed while it waits for a version that never answers, then a `yes` // piped in. Its health checks run `docker compose exec`, which forwards standard input to the container: the answer // must still reach the question, and the old image, the data and .env (on a version tag) come back exactly. it("after an update killed while it waits, a piped yes puts the old image, the data and .env back", () => { const box = install({ behaviour: "silent" }, { images: [{ ...OLD, refs: [...OLD.refs, `${REPO}:v0.1.150`] }, { ...NEW, behaviour: "silent" }], signalOn: { match: "/api/health\"", signal: "SIGKILL" }, }); writeFileSync(join(box.deploy, ".env"), `${ENV}OMB_IMAGE=${REPO}:v0.1.150\n`, { mode: 0o600 }); const envBefore = box.env(); const before = tree(box.data); expect(box.run().signal).toBe("SIGKILL"); expect(box.journal()).toBe(true); expect(box.state().containers.omb.image).toBe(NEW.id); const unanswered = box.run([], { input: "" }); expect(unanswered.status, unanswered.stdout).toBe(4); expect(box.state().containers.omb.image).toBe(NEW.id); const yes = box.run([], { input: "yes\n" }); expect(yes.said.at(-1), yes.stdout).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(yes.status).toBe(1); expect(box.state().containers.omb.image).toBe(OLD.id); expect(box.env()).toBe(envBefore); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); expect(box.journal()).toBe(false); }); it("asks before it undoes an update an earlier run left unhealthy, says what that loses, and changes nothing without a yes", () => { const box = install({ behaviour: "silent" }, { signalOn: { step: "compose up", signal: "SIGKILL" } }); const before = tree(box.data); const killed = box.run(); expect(killed.signal).toBe("SIGKILL"); expect(box.journal()).toBe(true); expect(box.env()).toContain("OMB_IMAGE="); const next = box.run(["--check"]); expect(next.status).toBe(0); expect(next.stdout).toContain("An earlier update did not finish."); expect(box.journal()).toBe(true); // The new version has been running on the data since: nothing may take that away unasked. const since = tree(box.data); for (const unanswered of [box.run(), box.run([], { input: "no\n" }), box.run(["--rollback"])]) { expect(unanswered.status, unanswered.stdout).toBe(4); expect(unanswered.stdout).toMatch(/An earlier update to 0\.1\.151, started \S+Z, did not finish/); expect(unanswered.stdout).toContain("Everything made on this server since then (chats, files, settings) is lost."); expect(unanswered.said.at(-1)).toContain("Nothing was changed."); expect(tree(box.data)).toEqual(since); expect(box.journal()).toBe(true); } const repaired = box.run([], { input: "yes\n" }); expect(repaired.said.slice(-3)).toEqual([ "Going back, as asked.", "Putting back the version and the data from before the update…", "Your server runs 0.1.150 again, with its data as it was before the update.", ]); expect(repaired.status).toBe(1); expect(box.env()).toBe(ROLLED_BACK); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); expect(box.state().containers.omb.image).toBe(OLD.id); }); it("keeps a new version that turned out healthy when the update was killed while waiting for it", () => { const box = install({}, { signalOn: { match: "/api/health\"", signal: "SIGKILL" } }); expect(box.run().signal).toBe("SIGKILL"); const next = box.run(); expect(next.status).toBe(0); expect(next.said.slice(0, 2)).toEqual(["The last update was interrupted after the new version had started, and it is healthy.", "Updated from 0.1.150 to 0.1.151."]); expect(next.said.at(-1)).toBe("This server already runs 0.1.151. Nothing to do."); expect(box.state().containers.omb.image).toBe(NEW.id); }); it("on Ctrl-C while copying, starts the server again with nothing changed; while waiting, puts the old version back", () => { const copying = install({}, { signalOn: { match: "--entrypoint cp ", signal: "SIGINT" } }); const stopped = copying.run(); expect(stopped.status).toBe(130); expect(stopped.stdout).toContain("The update stopped while it copied the data aside. Nothing was changed, and the server was started again."); expect(copying.state().containers.omb).toMatchObject({ image: OLD.id, status: "running" }); expect(copying.env()).toBe(ENV); expect(copying.journal()).toBe(false); const waiting = install({}, { signalOn: { match: "/api/health\"", signal: "SIGTERM" } }); const before = tree(waiting.data); const back = waiting.run(); expect(back.status).toBe(1); expect(back.said.at(-1)).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(waiting.env()).toBe(ROLLED_BACK); expect(tree(waiting.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); }); it("goes back on request after a good update, once, and only after a typed yes", () => { const box = install(); const before = tree(box.data); expect(box.run().status).toBe(0); writeFileSync(join(box.data, ".openmausbot", "bots.json"), "[]"); const declined = box.run(["--rollback"], { input: "no\n" }); expect(declined.status).toBe(4); expect(box.state().containers.omb.image).toBe(NEW.id); const back = box.run(["--rollback"], { input: "yes\n" }); expect(back.status, back.stdout).toBe(0); expect(back.said.at(-1)).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); expect(box.env()).toBe(ROLLED_BACK); expect(box.run(["--rollback", "--yes"]).stdout).toContain("There is no earlier update to go back from."); }); // A released version's own tag, or a digest, never moves: .env comes back byte for byte. Only a moving tag (`stable`, // `latest`, or none) is replaced by the digest of the version that ran. it("puts .env back exactly when it named a tag that does not move", () => { for (const named of [`${REPO}:v0.1.150`, OLD.refs[1]!]) { const box = install({}, { images: [{ ...OLD, refs: [...OLD.refs, `${REPO}:v0.1.150`] }, NEW] }); writeFileSync(join(box.deploy, ".env"), `${ENV}OMB_IMAGE=${named}\n`, { mode: 0o600 }); const before = box.env(); expect(box.run().status).toBe(0); expect(box.env()).not.toBe(before); const back = box.run(["--rollback", "--yes"]); expect(back.status, back.stdout).toBe(0); expect(box.env(), named).toBe(before); } const moving = install(); writeFileSync(join(moving.deploy, ".env"), `${ENV}OMB_IMAGE=${REPO}:stable\n`); expect(moving.run().status).toBe(0); expect(moving.run(["--rollback", "--yes"]).status).toBe(0); expect(moving.env()).toBe(`${ENV}OMB_IMAGE=${OLD.refs[1]}\n`); }); // A hardened server answers `openmausbot doctor` without a session with a refusal, so before the server's own count // the check that a bot can still start never ran there. it("checks a hardened version's engines with the server's own count, where doctor is refused", () => { const hardened = { counts: true, doctor: false }; const lost = install({ ...hardened, engine: false }); lost.setState({ images: [{ ...OLD, ...hardened }, { ...NEW, ...hardened, engine: false }] }); const failed = lost.run(); expect(failed.status, failed.stdout).toBe(1); expect(failed.stdout).toContain("The update to 0.1.151 did not work: no engine can start a bot on it, and one could before."); const kept = install(hardened); kept.setState({ images: [{ ...OLD, ...hardened }, { ...NEW, ...hardened }] }); expect(kept.run().status).toBe(0); expect(kept.state().containers.omb.image).toBe(NEW.id); }); it("moves an install the installer made with Caddy in the server's network to the current files, and back on rollback", () => { const box = install(); const oldCompose = `# Written by MausBot's installer (deploy/install.sh), and written again each time it runs. name: openmausbot services: omb: image: \${OMB_IMAGE:?run the installer again} caddy: image: caddy:2 network_mode: "service:omb" `; const oldCaddyfile = "{$DOMAIN} {\n\treverse_proxy 127.0.0.1:8799\n}\n"; writeFileSync(join(box.deploy, "compose.yaml"), oldCompose); writeFileSync(join(box.deploy, "Caddyfile"), oldCaddyfile); expect(box.run(["--check"]).stdout).toContain("It would also move Caddy to its own container"); const done = box.run(); expect(done.status, done.stdout).toBe(0); expect(done.said).toContain("Caddy moves to its own container, so HTTPS stays up whenever the server restarts on its own."); const compose = readFileSync(join(box.deploy, "compose.yaml"), "utf8"); expect(compose).not.toContain("network_mode"); expect(compose).toContain("- edge:/run/openmausbot"); expect(readFileSync(join(box.deploy, "Caddyfile"), "utf8")).toContain("reverse_proxy unix//run/openmausbot/http.sock"); expect(box.run(["--rollback", "--yes"]).status).toBe(0); expect(readFileSync(join(box.deploy, "compose.yaml"), "utf8")).toBe(oldCompose); expect(readFileSync(join(box.deploy, "Caddyfile"), "utf8")).toBe(oldCaddyfile); expect(box.env()).toBe(ROLLED_BACK); }); it("says how to move this repository's docker-compose.yml when it still has Caddy in the server's network", () => { const box = install(); writeFileSync(join(box.deploy, "docker-compose.yml"), 'services:\n caddy:\n network_mode: "service:omb"\n'); expect(box.run(["--check"]).stdout).toContain("git pull brings the current one"); const done = box.run(); expect(done.status).toBe(0); expect(done.stdout).toContain("Note: this docker-compose.yml runs Caddy in the server's network"); expect(readFileSync(join(box.deploy, "docker-compose.yml"), "utf8")).toContain("network_mode"); }); it("puts the old version back when Docker cannot start the new one at all", () => { const box = install({}, { failUp: NEW.id }); const failed = box.run(); expect(failed.status).toBe(1); expect(failed.stdout).toContain("Docker could not start 0.1.151"); expect(failed.said.at(-1)).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(box.state().containers.omb).toMatchObject({ image: OLD.id, status: "running" }); expect(box.env()).toBe(ROLLED_BACK); }); it("when the data cannot be put back: leaves the data and the copy as they are, keeps the journal, and tries again only when asked", () => { const box = install({ behaviour: "silent" }, { failRestore: true }); const before = tree(box.data); const failed = box.run(); expect(failed.status).toBe(2); expect(failed.stdout).toContain(`The data could not be put back from the Docker volume ${box.copies()[0]}`); expect(failed.said.at(-1)).toContain("./update.sh --rollback tries again"); // Nothing half-restored, no old version started on the data the new one left, and nothing forgotten. const left = tree(box.data); expect(left).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.151\n" }); expect(tree(box.backup())).toEqual(before); expect(box.state().containers.omb.status).toBe("exited"); expect(box.env()).toBe(`${ENV}OMB_IMAGE=${NEW.refs[0]}\n`); expect(box.journal()).toBe(true); // The next update neither replaces the copy nor touches the data until someone says yes. box.setState({ failRestore: false }); const next = box.run(); expect(next.status).toBe(4); expect(next.stdout).toContain("was being undone, and putting its data back did not finish."); expect(tree(box.data)).toEqual(left); expect(tree(box.backup())).toEqual(before); expect(box.calls().filter((call) => call.includes("--entrypoint cp"))).toHaveLength(1); const back = box.run(["--rollback", "--yes"]); expect(back.status, back.stdout).toBe(0); expect(back.said.at(-1)).toBe("Your server runs 0.1.150 again, with its data as it was before the update."); expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); expect(box.env()).toBe(ROLLED_BACK); expect(box.journal()).toBe(false); }); it("updates to a named version, and --check changes nothing", () => { const box = install(); const check = box.run(["--check"]); expect(check.said).toEqual(["This server runs 0.1.150.", "./update.sh would update it to 0.1.151."]); expect(box.calls().some((call) => /^(pull|compose (stop|up)|run|volume)/.test(call))).toBe(false); expect(existsSync(join(box.deploy, ".update"))).toBe(false); const done = box.run(["--version", "v0.1.151"]); expect(done.status, done.stdout).toBe(0); expect(box.calls()).toContain(`pull -q ${REPO}:v0.1.151`); expect(box.env()).toBe(`${ENV}OMB_IMAGE=${NEW.refs[0]}\n`); }); it("reads the version from the record's top level, whatever its history holds", () => { const box = install(); writeFileSync(join(box.root, "stable.json"), stableRecord("0.1.151", NEW.refs[0]!, ["0.1.150", "0.1.149", "0.1.148"])); const done = box.run(); expect(done.status, done.stdout).toBe(0); expect(done.said).toContain("Updated from 0.1.150 to 0.1.151."); // Keys in any order, on one line, with the history before the version. const again = install(); writeFileSync(join(again.root, "stable.json"), JSON.stringify({ history: [{ version: "0.1.151" }, { version: "0.1.140" }], image: NEW.refs[0], version: "0.1.151" })); expect(again.run().said).toContain("Updated from 0.1.150 to 0.1.151."); // A record whose top level names no version is refused, not read from its history. const bare = install(); writeFileSync(join(bare.root, "stable.json"), JSON.stringify({ image: NEW.refs[0], history: [{ version: "0.1.151" }] })); const refused = bare.run(); expect(refused.status).toBe(4); expect(refused.stdout).toContain("The stable channel names no image this script accepts."); }); it("carries on when the terminal closes in the middle of an update", () => { const box = install({}, { signalOn: { match: "/api/health\"", signal: "SIGHUP" } }); const done = box.run(); expect(done.signal).toBe(null); expect(done.status, done.stdout).toBe(0); expect(done.said).toContain("Updated from 0.1.150 to 0.1.151."); expect(box.state().containers.omb.image).toBe(NEW.id); expect(box.journal()).toBe(false); }); it("on Ctrl-C while it looks at an earlier run's unfinished update, changes nothing", () => { const box = install({ behaviour: "silent" }, { signalOn: { step: "compose up", signal: "SIGKILL" } }); expect(box.run().signal).toBe("SIGKILL"); const since = tree(box.data); box.setState({ signalOn: { match: "State.Status", signal: "SIGINT" } }); const stopped = box.run([], { input: "yes\n" }); expect(stopped.status).toBe(130); expect(stopped.said.slice(-2)).toEqual(["Interrupted.", "Nothing was changed."]); expect(box.journal()).toBe(true); expect(tree(box.data)).toEqual(since); expect(box.calls().some((call) => call.includes(".omb-restore"))).toBe(false); }); it("keeps the server as it is when an earlier update is left unfinished and the owner says keep", () => { const box = install({ behaviour: "silent" }, { signalOn: { match: "/api/health\"", signal: "SIGKILL" } }); expect(box.run().signal).toBe("SIGKILL"); const kept = box.run([], { input: "keep\n" }); expect(kept.status, kept.stdout).toBe(0); expect(kept.stdout).toContain("Left as it is."); expect(kept.said.at(-1)).toBe("This server already runs 0.1.151. Nothing to do."); expect(box.state().containers.omb.image).toBe(NEW.id); expect(box.journal()).toBe(false); expect(box.last()).toBe(true); }); it("never goes to an older version unless told to, judged by the version the server says it is", () => { // 0.1.152 runs, and stable is still 0.1.151. const newer = install(); newer.setState({ images: [{ ...OLD, version: "0.1.152" }, NEW] }); const refused = newer.run(); expect(refused.status).toBe(5); expect(refused.stdout).toContain("This server runs 0.1.152, which is newer than 0.1.151."); expect(refused.said.at(-1)).toContain("Nothing was changed. To go ahead anyway, with the data copied first: ./update.sh --allow-older"); expect(newer.calls().some((call) => /^(compose (stop|up)|run)/.test(call))).toBe(false); expect(newer.env()).toBe(ENV); expect(newer.journal()).toBe(false); expect(newer.copies()).toEqual([]); expect(newer.run(["--check"]).stdout).toContain("./update.sh would change nothing without --allow-older."); const told = newer.run(["--allow-older"]); expect(told.status, told.stdout).toBe(0); expect(told.said).toContain("Updated from 0.1.152 to 0.1.151."); // A build of main (`latest`) is labelled with the branch; it says the version of the release it was built after. const main = install(); main.setState({ images: [{ ...OLD, version: undefined, label: "main", serves: "0.1.151" }, NEW] }); const same = main.run(); expect(same.status).toBe(5); expect(same.stdout).toContain("This server runs a build of main that says 0.1.151, and may hold changes made after 0.1.151 was released."); const behind = install(); behind.setState({ images: [{ ...OLD, version: undefined, label: "main", serves: "0.1.150" }, NEW] }); const moved = behind.run(); expect(moved.status, moved.stdout).toBe(0); expect(behind.state().containers.omb.image).toBe(NEW.id); }); it("does not copy the data until the server has stopped, and drops a copy that does not match", () => { for (const [extra, words] of [ [{ failStop: true }, "The server could not be stopped"], [{ corruptCopy: true }, "Copying the data aside failed, or the copy did not match the data"], ] as const) { const box = install({}, extra); const before = tree(box.data); const refused = box.run(); expect(refused.status, words).toBe(4); expect(refused.said.at(-1)).toContain(words); expect(refused.said.at(-1)).toContain("Nothing was changed, and the server was started again."); expect(box.state().containers.omb.image).toBe(OLD.id); expect(box.env()).toBe(ENV); expect(box.copies()).toEqual([]); expect(box.journal()).toBe(false); if ("failStop" in extra) expect(box.calls().some((call) => call.includes("--entrypoint cp"))).toBe(false); else expect(tree(box.data)).toEqual({ ...before, "started-by.txt": "0.1.150\n0.1.150\n" }); } }); it("keeps the copy from the update before until the next copy has been checked", () => { const NEWER = { id: id("c"), refs: [`${REPO}@${id("3")}`, `${REPO}:v0.1.152`], version: "0.1.152", local: false, engine: true }; const box = install(); expect(box.run().status).toBe(0); const [first] = box.copies(); box.setState({ images: [...box.state().images, NEWER] }); writeFileSync(join(box.root, "stable.json"), stableRecord("0.1.152", NEWER.refs[0]!, ["0.1.151"])); box.setState({ corruptCopy: true }); expect(box.run().status).toBe(4); expect(box.copies()).toEqual([first]); expect(box.last()).toBe(true); const done = box.run(); expect(done.status, done.stdout).toBe(0); expect(done.said).toContain("Updated from 0.1.151 to 0.1.152."); expect(box.copies()).toHaveLength(1); expect(box.copies()[0]).not.toBe(first); }); it("takes the newest release until a first version is made stable, and stops on a channel it cannot reach", () => { const box = install(); rmSync(join(box.root, "stable.json")); writeFileSync(join(box.root, "newest.json"), JSON.stringify({ tag_name: "v0.1.151", html_url: "https://example.test/release", assets: [{ name: "x", version: "9.9.9" }] })); const done = box.run(); expect(done.status, done.stdout).toBe(0); expect(done.said[0]).toBe("No version has been made stable yet, so this takes the newest release, 0.1.151."); expect(box.calls()).toContain(`pull -q ${REPO}:v0.1.151`); expect(box.env()).toBe(`${ENV}OMB_IMAGE=${NEW.refs[0]}\n`); const unreachable = install(); const refused = unreachable.run([], { env: { OMB_STABLE_URL: "http://127.0.0.1:9/stable.json" } }); expect(refused.status).toBe(4); expect(refused.stdout).toContain("Could not read the stable channel"); expect(unreachable.calls().some((call) => call.startsWith("pull"))).toBe(false); }); it.skipIf(process.getuid?.() === 0)("says plainly that the install folder cannot be written, and changes nothing", () => { const box = install(); chmodSync(box.deploy, 0o555); try { const refused = box.run(); expect(refused.status).toBe(4); expect(refused.stdout).toContain("cannot be written by this user"); } finally { chmodSync(box.deploy, 0o755); } }); });