import assert from "node:assert/strict"; import test from "node:test"; import { normalizeRollbackPortMappings, replaceRuntimeForRollback, resolveRollbackImageReference, RollbackRuntimeError, } from "../../src/server/services/deployment-rollback.service"; type RollbackInput = Parameters[0]; type RollbackDependencies = NonNullable< Parameters[1] >; const server = {} as RollbackInput["server"]; test("normalizes Docker display port mappings for rollback", () => { assert.equal( normalizeRollbackPortMappings( "0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 127.0.0.1:8443->443/tcp", ), "8080:80,127.0.0.1:8443:443", ); assert.equal( normalizeRollbackPortMappings(["8081:81", "9000->90/udp"]), "8081:81,9000:90/udp", ); }); test("uses a locally available Docker image ID without creating an invalid registry reference", async () => { const inspected: string[] = []; const pulled: string[] = []; const image = await resolveRollbackImageReference( { server, image: "example/app:latest", imageDigest: `sha256:${"a".repeat(64)}`, }, { dockerInspect: async (_server, imageRef) => { inspected.push(imageRef); return {}; }, dockerPullImage: async (_server, imageRef) => { pulled.push(imageRef); }, }, ); assert.equal(image, `sha256:${"a".repeat(64)}`); assert.deepEqual(inspected, [`sha256:${"a".repeat(64)}`]); assert.deepEqual(pulled, []); }); test("falls back to the stored image when a historical local image ID was pruned", async () => { const pulled: string[] = []; const image = await resolveRollbackImageReference( { server, image: "example/app:stable", imageDigest: `sha256:${"b".repeat(64)}`, }, { dockerInspect: async () => { throw new Error("image not found"); }, dockerPullImage: async (_server, imageRef) => { pulled.push(imageRef); }, }, ); assert.equal(image, "example/app:stable"); assert.deepEqual(pulled, ["example/app:stable"]); }); function runtime(overrides: Partial = {}) { return { image: "example/app:target", ports: "", env: "APP_ENV=production", volumes: "/data:/data", network: "bridge", restartPolicy: "unless-stopped", command: "", ...overrides, }; } function dependencies( overrides: Partial = {}, ): RollbackDependencies { return { runContainer: async () => "new-container-id", stopAndRemoveContainer: async () => undefined, dockerRename: async () => undefined, waitForDockerHealth: async () => ({ healthy: true, reason: "running", status: "healthy", attempts: 1, durationMs: 0, }), ...overrides, }; } test("published-port rollback removes the active runtime before claiming its name and host port", async () => { const events: string[] = []; await replaceRuntimeForRollback( { server, containerName: "app", currentContainerRefs: ["old-container-id", "app"], temporaryName: "app-rollback", strategy: "RECREATE_WITH_RECOVERY", targetRuntime: runtime({ ports: "8080:80" }), previousRuntime: runtime({ image: "example/app:previous", ports: "8080:80", }), }, dependencies({ stopAndRemoveContainer: async (_server, refs) => { events.push(`remove:${refs.join(",")}`); }, runContainer: async (_server, options) => { events.push(`run:${options.name}:${options.ports}`); return "new-container-id"; }, waitForDockerHealth: async () => { events.push("runtime-check"); return { healthy: true, reason: "running", status: "healthy", attempts: 1, durationMs: 0, }; }, }), ); assert.deepEqual(events, [ "remove:old-container-id,app", "run:app:8080:80", "runtime-check", ]); }); test("failed published-port rollback recreates and verifies the previous runtime", async () => { const events: string[] = []; let runCount = 0; await assert.rejects( replaceRuntimeForRollback( { server, containerName: "app", currentContainerRefs: ["old-container-id", "app"], temporaryName: "app-rollback", strategy: "RECREATE_WITH_RECOVERY", targetRuntime: runtime({ ports: "8080:80" }), previousRuntime: runtime({ image: "example/app:previous", ports: "8080:80", }), }, dependencies({ stopAndRemoveContainer: async (_server, refs) => { events.push(`remove:${refs.join(",")}`); }, runContainer: async (_server, options) => { runCount += 1; events.push(`run:${options.image}`); return runCount === 1 ? "failed-target-id" : "recovered-container-id"; }, waitForDockerHealth: async ({ containerRef }) => { events.push(`runtime-check:${containerRef}`); if (containerRef === "failed-target-id") { return { healthy: false, reason: "not running", status: "not_running", attempts: 1, durationMs: 0, }; } return { healthy: true, reason: "running", status: "healthy", attempts: 1, durationMs: 0, }; }, }), ), (error: unknown) => { assert.ok(error instanceof RollbackRuntimeError); assert.equal(error.recoveryMode, "RECREATED"); assert.equal(error.recoveryDockerId, "recovered-container-id"); return true; }, ); assert.deepEqual(events, [ "remove:old-container-id,app", "run:example/app:target", "runtime-check:failed-target-id", "remove:failed-target-id,app-rollback,app", "run:example/app:previous", "runtime-check:recovered-container-id", ]); }); test("failed atomic candidate leaves the active runtime in place", async () => { const removedRefs: string[][] = []; let runCount = 0; await assert.rejects( replaceRuntimeForRollback( { server, containerName: "app", currentContainerRefs: ["old-container-id", "app"], temporaryName: "app-rollback", strategy: "ATOMIC_RENAME", targetRuntime: runtime(), previousRuntime: runtime({ image: "example/app:previous" }), }, dependencies({ stopAndRemoveContainer: async (_server, refs) => { removedRefs.push(refs); }, runContainer: async () => { runCount += 1; return "failed-target-id"; }, waitForDockerHealth: async ({ containerRef }) => { if (containerRef === "failed-target-id") { return { healthy: false, reason: "not running", status: "not_running", attempts: 1, durationMs: 0, }; } return { healthy: true, reason: "running", status: "healthy", attempts: 1, durationMs: 0, }; }, }), ), (error: unknown) => { assert.ok(error instanceof RollbackRuntimeError); assert.equal(error.recoveryMode, "UNCHANGED"); assert.equal(error.recoveryDockerId, "old-container-id"); return true; }, ); assert.equal(runCount, 1); assert.deepEqual(removedRefs, [["failed-target-id", "app-rollback"]]); });