test.deploy/core/steps/setup.step.test.ts
test.deploy/core/steps/setup.step.test.ts is a file in Codebase Testing. 179 lines of code and 0 definitions.
import {
PACKAGES_REVERTED,
PACKAGE_REVERT_UNAVAILABLE,
PACKAGE_UNAVAILABLE,
SERVER_CURRENT,
SERVER_READY,
} from "@banes-lab/deploy/configuration/strings/nginx.strings.ts";
import {
PACKAGE_SOURCES,
SITE_LINK,
SOURCE_MODULES,
} from "@banes-lab/deploy/configuration/constants/nginx.constants.ts";
import { describe, expect, it } from "vitest";
import { ensureServer, planPackages, prepareServer } from "@banes-lab/deploy/core/steps/setup.step.ts";
import { fakeJournal, fakeShell, ok } from "./shell.fixture.ts";
import type { CommandResult } from "@banes-lab/deploy/types/deployment.types.ts";
import { indexUrl } from "@banes-lab/deploy/core/converters/index.converter.ts";
import { markerOf } from "@banes-lab/deploy/core/steps/build.step.ts";
const [SERVER_SOURCE, BROTLI_SOURCE] = PACKAGE_SOURCES;
const PLATFORM = { architecture: "amd64", codename: "resolute" };
const stanza = function stanza(name: string, version: string, depends: string, provides = ""): string {
return [
`Package: ${name}`,
`Version: ${version}`,
`Depends: ${depends}`,
`Provides: ${provides}`,
`Filename: pool/${name}_${version}_amd64.deb`,
`SHA256: sha-${name}-${version}`,
"",
].join("\n");
};
const INDEXES = new Map([
[
indexUrl(SERVER_SOURCE ?? { base: "", component: "" }, "resolute", "amd64"),
[
stanza("nginx", "1.30.4-1~resolute", "libc6", "nginx, nginx-r1.30.4"),
stanza("nginx-module-njs", "1.30.4+1.0.1-1~resolute", "libxml2-16, nginx-r1.30.4"),
].join("\n"),
],
[
indexUrl(SERVER_SOURCE ?? { base: "", component: "" }, "noble", "amd64"),
stanza("nginx", "1.30.4-1~noble", "libc6", "nginx, nginx-r1.30.4"),
],
[
indexUrl(BROTLI_SOURCE ?? { base: "", component: "" }, "resolute", "amd64"),
stanza("libnginx-mod-brotli", "1.1.0+nginx-1.30.4-1~resolute", "nginx (= 1.30.4-1~resolute)"),
],
[
indexUrl(BROTLI_SOURCE ?? { base: "", component: "" }, "noble", "amd64"),
stanza("libnginx-mod-brotli", "1.1.0+nginx-1.30.4-1~noble", "nginx (= 1.30.4-1~noble)"),
],
]);
const fetchIndex = async (url: string): Promise<string> => {
await Promise.resolve();
return INDEXES.get(url) ?? "";
};
const status = function status(version: string): CommandResult {
return ok(`Status: install ok installed\nVersion: ${version}`);
};
const MARKERS = SOURCE_MODULES.map(markerOf).join("\n");
interface Server {
readonly installed: ReadonlyMap<string, string>;
readonly marker: string;
readonly testFails?: boolean;
}
const MIGRATING: Server = {
installed: new Map([
["nginx", "1.30.4-1~noble"],
["libnginx-mod-brotli", "1.1.0+nginx-1.30.4-1~noble"],
]),
marker: "",
};
const CURRENT: Server = {
installed: new Map([
["nginx", "1.30.4-1~resolute"],
["nginx-module-njs", "1.30.4+1.0.1-1~resolute"],
["libnginx-mod-brotli", "1.1.0+nginx-1.30.4-1~resolute"],
]),
marker: MARKERS,
};
const FAILED: CommandResult = { code: 1, stderr: "", stdout: "" };
const STATUS_PREFIX = "dpkg -s ";
const serverShell = function serverShell(server: Server): ReturnType<typeof fakeShell> {
const state = { installed: false };
const answers: readonly (readonly [(command: string) => boolean, (command: string) => CommandResult])[] = [
[
(command) => command.startsWith(STATUS_PREFIX),
(command) => {
const version = server.installed.get(command.slice(STATUS_PREFIX.length));
return version === undefined ? FAILED : status(version);
},
],
[(command) => command.includes("VERSION_CODENAME"), () => ok(PLATFORM.codename)],
[(command) => command.startsWith("dpkg --print-architecture"), () => ok(PLATFORM.architecture)],
[(command) => command.startsWith("test -f"), () => (server.marker.length > 0 ? ok(server.marker) : FAILED)],
[
(command) => command === "nginx -t" && server.testFails === true && state.installed,
() => {
state.installed = false;
return { code: 1, stderr: "module version mismatch", stdout: "" };
},
],
];
return fakeShell((command) => {
state.installed ||= command.includes("--no-remove install");
const answer = answers.find(([matches]) => matches(command));
return answer === undefined ? ok() : answer[1](command);
});
};
describe("planPackages", () => {
it("swaps nginx and every module to this release's build and keeps the old builds to restore", async () => {
const plan = await planPackages(serverShell(MIGRATING), fetchIndex, PLATFORM);
expect(plan.install.map((entry) => entry.version)).toStrictEqual([
"1.30.4-1~resolute",
"1.30.4+1.0.1-1~resolute",
"1.1.0+nginx-1.30.4-1~resolute",
]);
expect(plan.revert.map((entry) => entry.version)).toStrictEqual([
"1.30.4-1~noble",
"1.1.0+nginx-1.30.4-1~noble",
]);
expect(plan.added).toStrictEqual(["nginx-module-njs"]);
});
it("refuses before touching anything when a build to restore is no longer published", async () => {
const server: Server = { installed: new Map([["nginx", "1.30.4-1~jammy"]]), marker: "" };
await expect(planPackages(serverShell(server), fetchIndex, PLATFORM)).rejects.toThrow(
PACKAGE_REVERT_UNAVAILABLE,
);
});
it("refuses when no repository publishes the pinned nginx for this release", async () => {
const plan = planPackages(serverShell(MIGRATING), fetchIndex, { architecture: "amd64", codename: "plucky" });
await expect(plan).rejects.toThrow(PACKAGE_UNAVAILABLE);
});
});
describe("ensureServer", () => {
it("migrates a server in one guarded transaction that never removes a package", async () => {
const shell = serverShell(MIGRATING);
const journal = fakeJournal();
await ensureServer(shell, journal, fetchIndex);
const install = shell.commands.find((command) => command.includes("--no-remove install /")) ?? "";
expect(install).toContain("nginx_1.30.4-1~resolute_amd64.deb");
expect(install).toContain("nginx-module-njs_1.30.4+1.0.1-1~resolute_amd64.deb");
expect(install).toContain("libnginx-mod-brotli_1.1.0+nginx-1.30.4-1~resolute_amd64.deb");
expect(install).toContain("--force-confold");
expect(shell.commands.some((command) => command.includes("make modules"))).toBe(true);
expect(shell.commands.some((command) => command.includes("sha-nginx-1.30.4-1~noble"))).toBe(true);
expect(shell.commands.some((command) => command.includes("--allow-downgrades"))).toBe(false);
expect(shell.commands.at(-1)).toContain("rm -rf");
expect(journal.lines).toContain(SERVER_READY);
});
it("changes nothing on a second run and leaves the test to the push, so a broken managed file cannot block its own repair", async () => {
const shell = serverShell(CURRENT);
const journal = fakeJournal();
await ensureServer(shell, journal, fetchIndex);
expect(shell.commands.some((command) => command.includes("apt-get"))).toBe(false);
expect(shell.commands.some((command) => command.includes("make modules"))).toBe(false);
expect(shell.commands).not.toContain("nginx -t");
expect(journal.lines).toContain(SERVER_CURRENT);
});
it("restores the previous packages and modules when the configuration test fails, then fails", async () => {
const shell = serverShell({ ...MIGRATING, testFails: true });
const journal = fakeJournal();
await expect(ensureServer(shell, journal, fetchIndex)).rejects.toThrow("module version mismatch");
const revert = shell.commands.find((command) => command.includes("--allow-downgrades")) ?? "";
expect(revert).toContain("nginx_1.30.4-1~noble_amd64.deb");
expect(revert).toContain("remove nginx-module-njs");
expect(journal.lines).toContain(PACKAGES_REVERTED);
});
});
describe("prepareServer", () => {
it("links the site only after its configuration is pushed, so a fresh server never holds a dangling link", async () => {
const shell = serverShell(CURRENT);
await prepareServer(shell, fakeJournal(), fetchIndex);
const pushed = shell.commands.findIndex((command) => command.includes("sudo mv"));
const linked = shell.commands.findIndex((command) => command.includes(SITE_LINK));
expect(pushed).toBeGreaterThan(-1);
expect(linked).toBeGreaterThan(pushed);
});
});