## Summary - fall back to the existing scoped `RELEASE_MANAGER_GATE_TOKEN` when `SERVER_UPDATE_TOKEN` is absent - record the exact frontend SHA through the release-gate endpoint, then independently read it back - preserve the legacy dedicated-token path when it is configured - carry the scoped credential and exact run-attempt build ID through normal releases, rollback recovery, and restore-on-failure ## Dependency Depends on backend PR copenhagentruckwash/api#342 being merged and deployed before this PR is merged. ## Verification - focused release-gate updater test: 1 passed - direct exact-SHA update/readback execution passed - ESLint passed for changed JavaScript/tests - Prettier passed for both workflows and changed JavaScript/tests - Node syntax and `git diff --check` passed The existing broader cPanel release test is also updated; the local cached dependency set cannot collect that file because `jszip` is absent, so protected CI remains the full-suite authority.
900 lines
37 KiB
JavaScript
900 lines
37 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
CpanelFilemanClient,
|
|
DeploymentError,
|
|
assertExpectedCommitCurrent,
|
|
assertReleaseTargetExists,
|
|
buildLftpScript,
|
|
capturePublishedReleaseTarget,
|
|
containedRemotePath,
|
|
createLftpTransport,
|
|
deriveCpanelRoot,
|
|
deployRelease,
|
|
pruneInactiveReleases,
|
|
readDeploymentConfig,
|
|
rollbackRelease,
|
|
targetRelativeToRoot,
|
|
validateReleaseTarget,
|
|
} from "../../scripts/release/cpanel-deploy-lib.mjs";
|
|
import { updateServerVersion } from "../../scripts/release/update-server-version.mjs";
|
|
|
|
const temporaryDirectories = [];
|
|
const COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function validEnv(overrides = {}) {
|
|
return {
|
|
PRODUCTION_FTP_HOST: "ftp.example.test:21",
|
|
PRODUCTION_FTP_USER: "deploy-user",
|
|
PRODUCTION_FTP_PASSWORD: "password with ' quote",
|
|
PRODUCTION_FTP_PATH: "/",
|
|
PRODUCTION_ACTIVATION_KEY: "c".repeat(64),
|
|
PRODUCTION_CPANEL_USER: "cpanel-user",
|
|
PRODUCTION_CPANEL_API_TOKEN: "cpanel-token",
|
|
PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083/",
|
|
PRODUCTION_CPANEL_PATH: "/home/cpanel-user/public_html/frontend",
|
|
PRODUCTION_FRONTEND_URL: "https://app.example.test/",
|
|
RELEASE_ARCHIVE_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip",
|
|
RELEASE_ARCHIVE_SHA256_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip.sha256",
|
|
RELEASE_INVENTORY_PATH: "/tmp/pleno-vue-deadbeef-123-1.inventory.json",
|
|
RELEASE_EXPECTED_COMMIT: COMMIT_SHA,
|
|
RELEASE_EXPECTED_BUILD_ID: "123-1",
|
|
RELEASE_GITHUB_REPOSITORY: "pleno-dev/pleno-vue",
|
|
RELEASE_GITHUB_TOKEN: "github-token",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function config(overrides = {}) {
|
|
return {
|
|
...readDeploymentConfig(validEnv()),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))
|
|
);
|
|
});
|
|
|
|
describe("Control Plane release evidence", () => {
|
|
it("publishes V2 exact-release identity after the live gates", async () => {
|
|
const workflow = await fs.readFile(path.resolve(".github/workflows/release.yml"), "utf8");
|
|
expect(workflow).toContain("schemaVersion: 2");
|
|
expect(workflow).toContain('releaseId: required("RELEASE_ID")');
|
|
expect(workflow).toContain('sha: required("RELEASE_COMMIT_SHA").toLowerCase()');
|
|
expect(workflow).toContain('archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase()');
|
|
expect(workflow).toContain('activeTarget: required("RELEASE_ACTIVE_TARGET")');
|
|
expect(workflow).toContain("rollbackTarget:");
|
|
expect(workflow).toContain('verificationState: "verified"');
|
|
expect(workflow).toContain('releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME")');
|
|
expect(workflow).toContain('serverVersionReadBack: "passed"');
|
|
expect(workflow).toContain('if (configured === "false") return "not-configured"');
|
|
expect(workflow).toContain("steps.credentialed_live_config.outputs.configured == 'true'");
|
|
expect(workflow).toContain("Roll back after any post-deployment verification failure");
|
|
expect(workflow).toContain('RELEASE_VERSION_UPDATE_REQUIRED: "true"');
|
|
expect(workflow).toContain("RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}");
|
|
expect(workflow).toContain("npm run release:update-server-version");
|
|
expect(workflow).not.toContain("continue-on-error: true\n timeout-minutes: 5");
|
|
expect(workflow.indexOf("Roll back after any post-deployment verification failure")).toBeGreaterThan(
|
|
workflow.indexOf("Publish verified frontend release proof")
|
|
);
|
|
expect(workflow.indexOf("Create verified frontend release proof")).toBeGreaterThan(
|
|
workflow.indexOf("Credentialed live Playwright gate")
|
|
);
|
|
const testflight = await fs.readFile(path.resolve(".github/workflows/ios-testflight.yml"), "utf8");
|
|
expect(testflight).toContain("proof.schemaVersion === 2");
|
|
expect(testflight).toContain("proof.sha === process.env.IOS_SOURCE_SHA");
|
|
expect(testflight).toContain('proof.verificationState === "verified"');
|
|
expect(testflight).toContain('proof.serverVersionReadBack === "passed"');
|
|
expect(testflight).toContain('["passed", "not-configured"].includes(proof.liveCredentialedGate)');
|
|
});
|
|
|
|
it("keeps rollback and re-verification behind the protected production environment", async () => {
|
|
const recovery = await fs.readFile(path.resolve(".github/workflows/release-recovery.yml"), "utf8");
|
|
expect(recovery).toContain("environment: frontend-production");
|
|
expect(recovery).toContain("group: frontend-production");
|
|
expect(recovery).toContain("Authorize source from successful release proof");
|
|
expect(recovery).toContain("proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID");
|
|
expect(recovery).toContain("Rollback target does not match the verified release proof");
|
|
expect(recovery).toContain("Verify active manifest matches authorized release");
|
|
expect(recovery).toContain("Restore pre-recovery target after downstream failure");
|
|
expect(recovery).toContain("node scripts/release/deploy-cpanel.mjs --rollback");
|
|
expect(recovery).toContain('PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"');
|
|
expect(recovery).toContain("RELEASE_VERSION: ${{ inputs.source_sha }}");
|
|
expect(recovery).toContain("RELEASE_BUILD_ID: ${{ steps.authorize.outputs.build_id }}");
|
|
expect(recovery).toContain("RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}");
|
|
expect(recovery).toContain("PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}");
|
|
});
|
|
});
|
|
|
|
describe("server version release proof", () => {
|
|
it("requires a token and independently reads back the exact full SHA", async () => {
|
|
const version = "a".repeat(40);
|
|
const calls = [];
|
|
const fetchImpl = vi.fn(async (url) => {
|
|
calls.push(String(url));
|
|
return calls.length === 1
|
|
? new Response(JSON.stringify({ data: { accepted: true } }))
|
|
: new Response(JSON.stringify({ data: { version } }));
|
|
});
|
|
const result = await updateServerVersion(
|
|
{
|
|
SERVER_UPDATE_TOKEN: "test-token",
|
|
RELEASE_VERSION_UPDATE_REQUIRED: "true",
|
|
RELEASE_VERSION: version,
|
|
SERVER_UPDATE_URL: "https://api.example.test/worker/update-version",
|
|
SERVER_VERSION_READ_URL: "https://api.example.test/worker/version",
|
|
},
|
|
fetchImpl
|
|
);
|
|
expect(result.observed).toBe(version);
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]).toContain("/worker/version?verify=");
|
|
await expect(
|
|
updateServerVersion(
|
|
{
|
|
RELEASE_VERSION_UPDATE_REQUIRED: "true",
|
|
RELEASE_VERSION: version,
|
|
},
|
|
fetchImpl
|
|
)
|
|
).rejects.toThrow("SERVER_UPDATE_TOKEN or RELEASE_MANAGER_GATE_TOKEN is required");
|
|
});
|
|
});
|
|
|
|
describe("cPanel deployment configuration", () => {
|
|
it("validates every credential without putting values in errors", () => {
|
|
const secret = "never-print-this-token";
|
|
expect(() => readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` }))).toThrowError(
|
|
"PRODUCTION_CPANEL_API_TOKEN contains unsupported control characters"
|
|
);
|
|
|
|
try {
|
|
readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` }));
|
|
} catch (error) {
|
|
expect(error.message).not.toContain(secret);
|
|
}
|
|
});
|
|
|
|
it("derives a safe immutable release ID and accepts the checksum compatibility alias", () => {
|
|
const env = validEnv();
|
|
delete env.RELEASE_ARCHIVE_SHA256_PATH;
|
|
env.RELEASE_CHECKSUM_PATH = "/tmp/archive.zip.sha256";
|
|
const result = readDeploymentConfig(env);
|
|
|
|
expect(result.releaseId).toBe(`${COMMIT_SHA}-123-1`);
|
|
expect(result.checksumPath).toBe("/tmp/archive.zip.sha256");
|
|
expect(result.ftp.root).toBe("/");
|
|
expect(result.cpanel.root).toBe("public_html/frontend");
|
|
});
|
|
|
|
it("rejects path traversal and targets outside immutable releases", () => {
|
|
expect(() => containedRemotePath("public_html/frontend", "..", "outside")).toThrow(DeploymentError);
|
|
expect(() => validateReleaseTarget("staging/release/dist")).toThrow(DeploymentError);
|
|
expect(targetRelativeToRoot("public_html/frontend", "/home/user/public_html/frontend/releases/r1/dist")).toBe(
|
|
"releases/r1/dist"
|
|
);
|
|
});
|
|
|
|
it("maps an absolute FTP path to the API2 account-home-relative path", () => {
|
|
expect(deriveCpanelRoot("/home/cpanel-user/public_html/frontend", "cpanel-user")).toBe("public_html/frontend");
|
|
expect(() => deriveCpanelRoot("/home/different-user/public_html/frontend", "cpanel-user")).toThrow(
|
|
"not inside the configured cPanel account home"
|
|
);
|
|
});
|
|
|
|
it("does not require deploy artifacts for rollback-only configuration", () => {
|
|
const env = validEnv();
|
|
for (const name of [
|
|
"PRODUCTION_FRONTEND_URL",
|
|
"RELEASE_ARCHIVE_PATH",
|
|
"RELEASE_ARCHIVE_SHA256_PATH",
|
|
"RELEASE_INVENTORY_PATH",
|
|
"RELEASE_EXPECTED_COMMIT",
|
|
"RELEASE_EXPECTED_BUILD_ID",
|
|
"RELEASE_GITHUB_REPOSITORY",
|
|
"RELEASE_GITHUB_TOKEN",
|
|
]) {
|
|
delete env[name];
|
|
}
|
|
|
|
const result = readDeploymentConfig(env, { rollbackOnly: true });
|
|
|
|
expect(result.ftp).toEqual({
|
|
host: "ftp.example.test:21",
|
|
user: "deploy-user",
|
|
password: "password with ' quote",
|
|
root: "/",
|
|
});
|
|
expect(result.cpanel.root).toBe("public_html/frontend");
|
|
});
|
|
|
|
it("does not require cPanel API access for the hosted FTPS release path", () => {
|
|
const env = validEnv();
|
|
for (const name of [
|
|
"PRODUCTION_CPANEL_USER",
|
|
"PRODUCTION_CPANEL_API_TOKEN",
|
|
"PRODUCTION_CPANEL_API_URL",
|
|
"PRODUCTION_CPANEL_PATH",
|
|
]) {
|
|
delete env[name];
|
|
}
|
|
|
|
const result = readDeploymentConfig(env);
|
|
|
|
expect(result.cpanel).toBeUndefined();
|
|
expect(result.ftp.root).toBe("/");
|
|
});
|
|
});
|
|
|
|
describe("secure FTPS archive upload", () => {
|
|
it("reports actionable lftp diagnostics without exposing deployment credentials", async () => {
|
|
const deploymentConfig = config();
|
|
const runnerError = Object.assign(new Error(`connection to ${deploymentConfig.ftp.host} failed`), {
|
|
stderr: `ftps://${deploymentConfig.ftp.user}:${deploymentConfig.ftp.password}@${deploymentConfig.ftp.host}: 530 Login incorrect`,
|
|
stdout: `cd ${deploymentConfig.ftp.root}: Access failed`,
|
|
});
|
|
const transport = createLftpTransport(deploymentConfig, {
|
|
runner: vi.fn(async () => {
|
|
throw runnerError;
|
|
}),
|
|
});
|
|
|
|
let error;
|
|
try {
|
|
await transport.removeRelease("old-release");
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(DeploymentError);
|
|
expect(error.message).toContain("530 Login incorrect");
|
|
expect(error.message).toContain("[redacted]");
|
|
expect(error.message).not.toContain(deploymentConfig.ftp.host);
|
|
expect(error.message).not.toContain(deploymentConfig.ftp.user);
|
|
expect(error.message).not.toContain(deploymentConfig.ftp.password);
|
|
});
|
|
|
|
it("redacts overlapping credentials longest-first and host names case-insensitively", async () => {
|
|
const deploymentConfig = readDeploymentConfig(
|
|
validEnv({
|
|
PRODUCTION_FTP_HOST: "Ftp.Example.Test:21",
|
|
PRODUCTION_FTP_USER: "prod",
|
|
PRODUCTION_FTP_PASSWORD: "prod-secret",
|
|
PRODUCTION_FTP_PATH: "/deploy/prod",
|
|
})
|
|
);
|
|
const runnerError = Object.assign(new Error("FTPS failed"), {
|
|
stderr: "authentication failed for prod-secret at FTP.EXAMPLE.TEST:21 under /deploy/prod",
|
|
});
|
|
const transport = createLftpTransport(deploymentConfig, {
|
|
runner: vi.fn(async () => {
|
|
throw runnerError;
|
|
}),
|
|
});
|
|
|
|
let error;
|
|
try {
|
|
await transport.removeRelease("old-release");
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error.message).toContain("authentication failed");
|
|
expect(error.message).not.toContain("-secret");
|
|
expect(error.message).not.toContain("FTP.EXAMPLE.TEST:21");
|
|
expect(error.message).not.toContain("/deploy/prod");
|
|
});
|
|
|
|
it("forces verified TLS and keeps credentials out of command arguments", () => {
|
|
const deploymentConfig = config();
|
|
const script = buildLftpScript(deploymentConfig, ["bye"]);
|
|
|
|
expect(script).toContain("set ftp:ssl-force yes");
|
|
expect(script).toContain("set ftp:ssl-protect-data yes");
|
|
expect(script).toContain("set ftp:list-options -a");
|
|
expect(script).toContain("set ssl:verify-certificate yes");
|
|
expect(script).toContain("set ssl:check-hostname yes");
|
|
expect(script).toContain("open -u 'deploy-user','password with '\\'' quote'");
|
|
});
|
|
|
|
it("uploads .part files, downloads them for verification, then renames them", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-deploy-test-"));
|
|
temporaryDirectories.push(directory);
|
|
const archivePath = path.join(directory, "pleno-vue-deadbeef-123-1.zip");
|
|
const checksumPath = `${archivePath}.sha256`;
|
|
const archive = Buffer.from("verified release archive");
|
|
const hash = crypto.createHash("sha256").update(archive).digest("hex");
|
|
await fs.writeFile(archivePath, archive);
|
|
await fs.writeFile(checksumPath, `${hash} ${path.basename(archivePath)}\n`);
|
|
|
|
const calls = [];
|
|
const runner = vi.fn(async (command, args, options) => {
|
|
calls.push({ command, args, input: options.input });
|
|
const downloads = [...options.input.matchAll(/get '[^']+' -o '([^']+)'/g)].map((match) => match[1]);
|
|
if (downloads.length === 2) {
|
|
await fs.copyFile(archivePath, downloads[0]);
|
|
await fs.copyFile(checksumPath, downloads[1]);
|
|
}
|
|
});
|
|
const deploymentConfig = config({ archivePath, checksumPath });
|
|
const transport = createLftpTransport(deploymentConfig, { runner });
|
|
|
|
await expect(transport.uploadArchive()).resolves.toMatchObject({
|
|
archiveName: path.basename(archivePath),
|
|
sha256: hash,
|
|
});
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[0].args).toEqual([]);
|
|
expect(calls[0].input).toContain("mkdir -p -f 'archives'");
|
|
expect(calls[0].input).toContain(`${path.basename(archivePath)}.part`);
|
|
expect(calls[0].input).toContain("get 'archives/");
|
|
expect(calls[1].input).toContain(
|
|
`mv 'archives/${path.basename(archivePath)}.sha256.part' 'archives/${path.basename(archivePath)}.sha256'`
|
|
);
|
|
expect(calls[1].input).toContain(
|
|
`mv 'archives/${path.basename(archivePath)}.part' 'archives/${path.basename(archivePath)}'`
|
|
);
|
|
expect(calls[0].args.join(" ")).not.toContain(deploymentConfig.ftp.password);
|
|
});
|
|
|
|
it("permanently removes only validated inactive release and archive paths", async () => {
|
|
const scripts = [];
|
|
const runner = vi.fn(async (_command, _args, options) => scripts.push(options.input));
|
|
const transport = createLftpTransport(config(), { runner });
|
|
|
|
await transport.removeRelease("old-release");
|
|
await transport.removeArchives(["old-release"]);
|
|
|
|
expect(scripts.join("\n")).toContain("rm -r -f 'releases/old-release'");
|
|
expect(scripts.join("\n")).toContain("rm -f 'archives/pleno-vue-old-release.zip'");
|
|
await expect(transport.removeRelease("../outside")).rejects.toThrow("safe filename component");
|
|
});
|
|
|
|
it("queues a bounded activation request without replacing the account-scoped executable", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activation-request-test-"));
|
|
temporaryDirectories.push(directory);
|
|
const deploymentConfig = config();
|
|
const target = `releases/${deploymentConfig.releaseId}/dist`;
|
|
let uploadedRequest = "";
|
|
const scripts = [];
|
|
const runner = vi.fn(async (_command, _args, options) => {
|
|
scripts.push(options.input);
|
|
const requestMatch = options.input.match(
|
|
/put '([^']+\.request)' -o 'activation-requests\/gha-test\.request\.part'/
|
|
);
|
|
if (requestMatch) uploadedRequest = await fs.readFile(requestMatch[1], "utf8");
|
|
const resultMatch = options.input.match(/get 'activation-results\/gha-test\.result' -o '([^']+)'/);
|
|
if (resultMatch) {
|
|
await fs.writeFile(
|
|
resultMatch[1],
|
|
`schema_version=1\nrequest_id=gha-test\nstatus=success\ntarget=${target}\nmessage=activated\n`
|
|
);
|
|
}
|
|
});
|
|
const transport = createLftpTransport(deploymentConfig, {
|
|
runner,
|
|
activationRequestId: "gha-test",
|
|
now: () => 2_000_000_000_000,
|
|
});
|
|
|
|
await expect(transport.stageAndActivate({ archiveName: "release.zip", sha256: "a".repeat(64) })).resolves.toBe(
|
|
target
|
|
);
|
|
expect(scripts[0]).not.toContain("release-activate.sh");
|
|
expect(scripts[0]).toContain("mkdir -p -f 'activation-requests'");
|
|
expect(scripts[0]).toContain("mkdir -p -f 'activation-results'");
|
|
expect(uploadedRequest).toContain(`release_id=${deploymentConfig.releaseId}`);
|
|
expect(uploadedRequest).toContain(`commit_sha=${deploymentConfig.expectedCommit}`);
|
|
expect(uploadedRequest).toContain("archive_sha256=" + "a".repeat(64));
|
|
expect(uploadedRequest).toContain("expires_at=2000000150");
|
|
const [authenticatedBody, requestHmac] = uploadedRequest.split("request_hmac=");
|
|
expect(requestHmac.trim()).toBe(
|
|
crypto
|
|
.createHmac("sha256", Buffer.from(deploymentConfig.activationKey, "hex"))
|
|
.update(authenticatedBody)
|
|
.digest("hex")
|
|
);
|
|
});
|
|
|
|
it("polls for a committed result when the request upload outcome is ambiguous", async () => {
|
|
const deploymentConfig = config();
|
|
const target = `releases/${deploymentConfig.releaseId}/dist`;
|
|
let calls = 0;
|
|
const runner = vi.fn(async (_command, _args, options) => {
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("FTPS disconnected after the final rename");
|
|
const resultMatch = options.input.match(/get 'activation-results\/gha-ambiguous\.result' -o '([^']+)'/);
|
|
if (resultMatch) {
|
|
await fs.writeFile(
|
|
resultMatch[1],
|
|
`schema_version=1\nrequest_id=gha-ambiguous\nstatus=success\ntarget=${target}\nmessage=activated\n`
|
|
);
|
|
}
|
|
});
|
|
const transport = createLftpTransport(deploymentConfig, {
|
|
runner,
|
|
activationRequestId: "gha-ambiguous",
|
|
});
|
|
|
|
await expect(transport.activateExisting(target)).resolves.toBe(target);
|
|
expect(runner).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("account-scoped release activator", () => {
|
|
it("stages a validated archive, switches current atomically, and leaves current untouched on failure", async () => {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-activator-test-"));
|
|
temporaryDirectories.push(directory);
|
|
const activationRoot = path.join(directory, "frontend-deployments");
|
|
const activationKey = "c".repeat(64);
|
|
const activationKeyPath = path.join(directory, "activation.key");
|
|
const packageRoot = path.join(directory, "package");
|
|
const dist = path.join(packageRoot, "dist");
|
|
await fs.mkdir(path.join(activationRoot, "archives"), { recursive: true });
|
|
await fs.mkdir(path.join(activationRoot, "activation-requests"), { recursive: true });
|
|
await fs.writeFile(activationKeyPath, `${activationKey}\n`);
|
|
const writeDist = async (directoryPath, commit, buildId, marker = "app") => {
|
|
await fs.mkdir(directoryPath, { recursive: true });
|
|
await fs.writeFile(path.join(directoryPath, "index.html"), `<div id="${marker}"></div>`);
|
|
await fs.writeFile(path.join(directoryPath, ".htaccess"), "DirectoryIndex index.html\n");
|
|
await fs.writeFile(path.join(directoryPath, "release-entry.json"), "{}\n");
|
|
await fs.writeFile(
|
|
path.join(directoryPath, "release-manifest.json"),
|
|
JSON.stringify({ commit_sha: commit, build_id: buildId })
|
|
);
|
|
};
|
|
const oldCommit = "a".repeat(40);
|
|
const oldBuildId = "122-1";
|
|
const oldReleaseId = `${oldCommit}-${oldBuildId}`;
|
|
const oldTarget = `releases/${oldReleaseId}/dist`;
|
|
await writeDist(path.join(activationRoot, oldTarget), oldCommit, oldBuildId, "old-app");
|
|
await fs.symlink(oldTarget, path.join(activationRoot, "current"));
|
|
await writeDist(dist, COMMIT_SHA, "123-1");
|
|
const releaseId = `${COMMIT_SHA}-123-1`;
|
|
const target = `releases/${releaseId}/dist`;
|
|
const archiveName = `pleno-vue-${releaseId}.zip`;
|
|
const archivePath = path.join(activationRoot, "archives", archiveName);
|
|
await execFileAsync("zip", ["-qr", archivePath, "dist"], { cwd: packageRoot });
|
|
const archive = await fs.readFile(archivePath);
|
|
const archiveSha256 = crypto.createHash("sha256").update(archive).digest("hex");
|
|
const writeRequest = async (
|
|
requestId,
|
|
hash,
|
|
requestedReleaseId = releaseId,
|
|
requestedBuildId = "123-1",
|
|
action = "stage",
|
|
expiresAt = Math.floor(Date.now() / 1000) + 300
|
|
) => {
|
|
const requestBody = [
|
|
"schema_version=1",
|
|
`request_id=${requestId}`,
|
|
`action=${action}`,
|
|
`release_id=${requestedReleaseId}`,
|
|
`commit_sha=${COMMIT_SHA}`,
|
|
`build_id=${requestedBuildId}`,
|
|
`archive_name=${action === "stage" ? archiveName : "unused"}`,
|
|
`archive_sha256=${action === "stage" ? hash : "unused"}`,
|
|
`expires_at=${expiresAt}`,
|
|
"",
|
|
].join("\n");
|
|
const requestHmac = crypto
|
|
.createHmac("sha256", Buffer.from(activationKey, "hex"))
|
|
.update(requestBody)
|
|
.digest("hex");
|
|
await fs.writeFile(
|
|
path.join(activationRoot, "activation-requests", `${requestId}.request`),
|
|
`${requestBody}request_hmac=${requestHmac}\n`
|
|
);
|
|
};
|
|
const activator = path.resolve("scripts/release/cpanel-activate.sh");
|
|
const activatorEnvironment = {
|
|
...process.env,
|
|
CPANEL_ACTIVATION_ROOT: activationRoot,
|
|
CPANEL_ACTIVATION_KEY_FILE: activationKeyPath,
|
|
};
|
|
|
|
await writeRequest("activation-success", archiveSha256);
|
|
await execFileAsync("sh", [activator], { env: activatorEnvironment });
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
|
|
expect(await fs.readFile(path.join(activationRoot, "current", "index.html"), "utf8")).toContain('id="app"');
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
|
|
).toContain("status=success");
|
|
|
|
const processedSuccess = path.join(
|
|
activationRoot,
|
|
"activation-requests",
|
|
"processed",
|
|
"activation-success.processed"
|
|
);
|
|
const processingSuccess = path.join(activationRoot, "activation-requests", "activation-success.processing");
|
|
await fs.rename(processedSuccess, processingSuccess);
|
|
await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result"));
|
|
await execFileAsync("sh", [activator], { env: activatorEnvironment });
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
|
|
).toContain("status=success");
|
|
|
|
const failedReleaseId = `${COMMIT_SHA}-123-2`;
|
|
await writeRequest("activation-failure", "0".repeat(64), failedReleaseId, "123-2");
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-failure.result"), "utf8")
|
|
).toContain("status=failure");
|
|
|
|
const existingReleaseId = `${COMMIT_SHA}-123-3`;
|
|
await writeDist(path.join(activationRoot, "releases", existingReleaseId, "dist"), COMMIT_SHA, "123-3");
|
|
await writeRequest("activation-existing", archiveSha256, existingReleaseId, "123-3");
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-existing.result"), "utf8")
|
|
).toContain("message=release_already_exists");
|
|
|
|
await writeRequest(
|
|
"activation-expired",
|
|
archiveSha256,
|
|
`${COMMIT_SHA}-123-4`,
|
|
"123-4",
|
|
"stage",
|
|
Math.floor(Date.now() / 1000) - 1
|
|
);
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(`releases/${releaseId}/dist`);
|
|
|
|
await writeRequest("activation-unauthenticated", archiveSha256, `${COMMIT_SHA}-123-5`, "123-5");
|
|
const unauthenticatedRequest = path.join(
|
|
activationRoot,
|
|
"activation-requests",
|
|
"activation-unauthenticated.request"
|
|
);
|
|
await fs.writeFile(
|
|
unauthenticatedRequest,
|
|
(
|
|
await fs.readFile(unauthenticatedRequest, "utf8")
|
|
).replace(/request_hmac=[a-f0-9]{64}/, `request_hmac=${"0".repeat(64)}`)
|
|
);
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
|
|
|
|
await fs.rm(path.join(activationRoot, target, "index.html"));
|
|
await fs.rename(processedSuccess, processingSuccess);
|
|
await fs.rm(path.join(activationRoot, "activation-results", "activation-success.result"));
|
|
await fs.symlink(oldTarget, path.join(activationRoot, "current.activation-success.rollback"));
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(oldTarget);
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-success.result"), "utf8")
|
|
).toContain("message=post_activation_validation");
|
|
|
|
await fs.writeFile(path.join(activationRoot, target, "index.html"), '<div id="app"></div>');
|
|
await writeRequest("activation-switch", "unused", releaseId, "123-1", "switch");
|
|
await execFileAsync("sh", [activator], { env: activatorEnvironment });
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
|
|
|
|
const missingReleaseId = `${COMMIT_SHA}-missing`;
|
|
await writeRequest("activation-missing", "unused", missingReleaseId, "missing", "switch");
|
|
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
|
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
|
|
expect(
|
|
await fs.readFile(path.join(activationRoot, "activation-results", "activation-missing.result"), "utf8")
|
|
).toContain("message=release_validation");
|
|
});
|
|
});
|
|
|
|
describe("cPanel Fileman adapter", () => {
|
|
it("uses token authentication and API2 Fileman parameters over HTTPS", async () => {
|
|
const requests = [];
|
|
const fetchImpl = vi.fn(async (url, options) => {
|
|
requests.push({ url: new URL(url), options });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
async json() {
|
|
return {
|
|
cpanelresult: {
|
|
event: { result: "1" },
|
|
data: [{ result: 1 }],
|
|
},
|
|
};
|
|
},
|
|
};
|
|
});
|
|
const deploymentConfig = config();
|
|
const client = new CpanelFilemanClient(deploymentConfig, { fetchImpl });
|
|
|
|
await client.remove("public_html/frontend/staging/obsolete-release");
|
|
|
|
expect(requests).toHaveLength(1);
|
|
expect(requests[0].url.protocol).toBe("https:");
|
|
expect(requests[0].url.searchParams.get("cpanel_jsonapi_apiversion")).toBe("2");
|
|
expect(requests[0].url.searchParams.get("cpanel_jsonapi_module")).toBe("Fileman");
|
|
expect(requests[0].url.searchParams.get("cpanel_jsonapi_func")).toBe("fileop");
|
|
expect(requests[0].url.searchParams.get("op")).toBe("trash");
|
|
expect(requests[0].url.searchParams.get("sourcefiles")).toBe("public_html/frontend/staging/obsolete-release");
|
|
expect(requests[0].url.searchParams.has("destfiles")).toBe(false);
|
|
expect(requests[0].options.headers.Authorization).toBe("cpanel cpanel-user:cpanel-token");
|
|
});
|
|
|
|
it("redacts credentials echoed by a cPanel error", async () => {
|
|
const deploymentConfig = config();
|
|
const client = new CpanelFilemanClient(deploymentConfig, {
|
|
fetchImpl: async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
async json() {
|
|
return {
|
|
status: 0,
|
|
errors: [`bad ${deploymentConfig.cpanel.token}`],
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
await expect(client.list(deploymentConfig.cpanel.root)).rejects.toThrow("bad [redacted]");
|
|
});
|
|
|
|
it("lists deployment entries through the supported Fileman UAPI", async () => {
|
|
const fetchImpl = vi.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
status: "1",
|
|
data: [
|
|
{ file: "current", type: "link" },
|
|
{ file: "releases", type: "dir" },
|
|
],
|
|
}),
|
|
}));
|
|
const client = new CpanelFilemanClient(config(), { fetchImpl });
|
|
|
|
await expect(client.list(config().cpanel.root)).resolves.toEqual([
|
|
{ file: "current", type: "link" },
|
|
{ file: "releases", type: "dir" },
|
|
]);
|
|
const endpoint = fetchImpl.mock.calls[0][0];
|
|
expect(endpoint.pathname).toBe("/execute/Fileman/list_files");
|
|
expect(endpoint.searchParams.get("dir")).toBe("public_html/frontend");
|
|
expect(endpoint.searchParams.get("show_hidden")).toBe("1");
|
|
expect(endpoint.searchParams.get("types")).toBe("dir|file|link");
|
|
});
|
|
|
|
it("captures the rollback target from the live release manifest", async () => {
|
|
const deploymentConfig = config();
|
|
const fetchImpl = vi.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ commit_sha: COMMIT_SHA, build_id: "122-1" }),
|
|
}));
|
|
|
|
await expect(capturePublishedReleaseTarget(deploymentConfig, { fetchImpl })).resolves.toBe(
|
|
`releases/${COMMIT_SHA}-122-1/dist`
|
|
);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
new URL("release-manifest.json", deploymentConfig.frontendUrl),
|
|
expect.objectContaining({ headers: expect.objectContaining({ "Cache-Control": "no-cache" }) })
|
|
);
|
|
});
|
|
|
|
it("rejects a stale commit immediately before activation", async () => {
|
|
const deploymentConfig = config();
|
|
const fetchImpl = vi.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ commit: { sha: "0123456789abcdef0123456789abcdef01234567" } }),
|
|
}));
|
|
|
|
await expect(assertExpectedCommitCurrent(deploymentConfig, { fetchImpl })).rejects.toThrow(
|
|
"Release commit is stale"
|
|
);
|
|
});
|
|
});
|
|
|
|
function inMemorySwitchClient(deploymentConfig, initialTarget) {
|
|
let current = initialTarget;
|
|
let next = "";
|
|
const root = deploymentConfig.cpanel.root;
|
|
const entriesForRoot = () => [
|
|
{ file: "archives", type: "dir" },
|
|
{ file: "releases", type: "dir" },
|
|
{ file: "staging", type: "dir" },
|
|
...(current ? [{ file: "current", type: "link", link: `${root}/${current}` }] : []),
|
|
...(next ? [{ file: "current.next", type: "link", link: `${root}/${next}` }] : []),
|
|
];
|
|
return {
|
|
get current() {
|
|
return current;
|
|
},
|
|
setCurrent(target) {
|
|
current = target;
|
|
},
|
|
async list(directory) {
|
|
if (directory === root) return entriesForRoot();
|
|
if (directory === `${root}/archives`) return [];
|
|
if (directory === `${root}/releases`) {
|
|
return current ? [{ file: current.split("/")[1], type: "dir" }] : [];
|
|
}
|
|
if (current && directory === `${root}/releases/${current.split("/")[1]}`) {
|
|
return [{ file: "dist", type: "dir" }];
|
|
}
|
|
return [];
|
|
},
|
|
async link(source, destination) {
|
|
expect(destination).toBe(`${root}/current.next`);
|
|
next = targetRelativeToRoot(root, source);
|
|
},
|
|
async rename(source, destination) {
|
|
expect(source).toBe(`${root}/current.next`);
|
|
expect(destination).toBe(`${root}/current`);
|
|
current = next;
|
|
next = "";
|
|
},
|
|
async unlink() {
|
|
next = "";
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("activation, rollback, and retention", () => {
|
|
it("rejects a manifest-derived rollback target that is absent on cPanel", async () => {
|
|
const deploymentConfig = config();
|
|
const client = { list: vi.fn(async () => []) };
|
|
|
|
await expect(
|
|
assertReleaseTargetExists(client, deploymentConfig, `releases/${COMMIT_SHA}-122-1/dist`)
|
|
).rejects.toThrow("rollback release directory does not exist");
|
|
});
|
|
|
|
it("restores the previous pointer when public verification fails", async () => {
|
|
const deploymentConfig = config();
|
|
const previousTarget = "releases/previous-release/dist";
|
|
const newTarget = `releases/${deploymentConfig.releaseId}/dist`;
|
|
const client = inMemorySwitchClient(deploymentConfig, previousTarget);
|
|
const outputs = [];
|
|
const verifyRelease = vi.fn();
|
|
const transport = {
|
|
uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }),
|
|
stageAndActivate: async () => {
|
|
client.setCurrent(newTarget);
|
|
return newTarget;
|
|
},
|
|
activateExisting: async (target) => client.setCurrent(target),
|
|
verifyRelease,
|
|
};
|
|
|
|
await expect(
|
|
deployRelease(deploymentConfig, {
|
|
client,
|
|
transport,
|
|
capturePrevious: async () => previousTarget,
|
|
checkCurrent: async () => {},
|
|
verify: async () => {
|
|
throw new Error("application gate failed");
|
|
},
|
|
prune: async () => [],
|
|
publish: (values) => outputs.push(values),
|
|
})
|
|
).rejects.toThrow("the previous release was restored");
|
|
|
|
expect(client.current).toBe(previousTarget);
|
|
expect(verifyRelease).toHaveBeenCalledWith(newTarget);
|
|
expect(outputs[0]).toMatchObject({
|
|
RELEASE_ROLLBACK_TARGET: previousTarget,
|
|
RELEASE_ACTIVE_TARGET: newTarget,
|
|
});
|
|
});
|
|
|
|
it("reconciles a lost activation result before verifying the committed release", async () => {
|
|
const deploymentConfig = config();
|
|
delete deploymentConfig.cpanel;
|
|
const previousTarget = `releases/${"a".repeat(40)}-122-1/dist`;
|
|
const newTarget = `releases/${deploymentConfig.releaseId}/dist`;
|
|
const verifyRelease = vi.fn();
|
|
const verify = vi.fn();
|
|
const transport = {
|
|
uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }),
|
|
stageAndActivate: async () => {
|
|
throw new DeploymentError("Timed out waiting for the account-scoped release activator.");
|
|
},
|
|
activateExisting: vi.fn(),
|
|
verifyRelease,
|
|
};
|
|
|
|
await expect(
|
|
deployRelease(deploymentConfig, {
|
|
transport,
|
|
capturePrevious: async () => previousTarget,
|
|
captureActive: async () => newTarget,
|
|
checkCurrent: async () => {},
|
|
verify,
|
|
prune: async () => [],
|
|
publish: () => {},
|
|
})
|
|
).resolves.toMatchObject({ activeTarget: newTarget });
|
|
|
|
expect(verifyRelease).toHaveBeenCalledWith(newTarget);
|
|
expect(verify).toHaveBeenCalled();
|
|
expect(transport.activateExisting).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports an idempotent rollback-only workflow step", async () => {
|
|
const deploymentConfig = config();
|
|
const target = "releases/previous-release/dist";
|
|
const client = inMemorySwitchClient(deploymentConfig, target);
|
|
const publish = vi.fn();
|
|
const activateExisting = vi.fn(async () => target);
|
|
|
|
await expect(
|
|
rollbackRelease(deploymentConfig, target, {
|
|
client,
|
|
transport: { activateExisting },
|
|
publish,
|
|
})
|
|
).resolves.toMatchObject({ activeTarget: target });
|
|
expect(activateExisting).toHaveBeenCalledWith(target);
|
|
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ RELEASE_ACTIVE_TARGET: target }));
|
|
});
|
|
|
|
it("propagates server-side rollback target validation failures without publishing outputs", async () => {
|
|
const deploymentConfig = config();
|
|
const activateExisting = vi.fn(async () => {
|
|
throw new DeploymentError("Server-side release activation failed during release_validation.");
|
|
});
|
|
const publish = vi.fn();
|
|
|
|
await expect(
|
|
rollbackRelease(deploymentConfig, "releases/missing-release/dist", {
|
|
transport: { activateExisting },
|
|
publish,
|
|
})
|
|
).rejects.toThrow("release_validation");
|
|
expect(activateExisting).toHaveBeenCalledWith("releases/missing-release/dist");
|
|
expect(publish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prunes only inactive releases and preserves active and rollback targets", async () => {
|
|
const deploymentConfig = config({ retainCount: 2 });
|
|
const removed = [];
|
|
const client = {
|
|
async list() {
|
|
return [
|
|
{ file: "active", type: "dir", mtime: 50 },
|
|
{ file: "rollback", type: "dir", mtime: 40 },
|
|
{ file: "old", type: "dir", mtime: 30 },
|
|
{ file: ".trash", type: "dir", mtime: 1 },
|
|
];
|
|
},
|
|
async remove(remotePath) {
|
|
removed.push(remotePath);
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist", "releases/rollback/dist"])
|
|
).resolves.toEqual(["old"]);
|
|
expect(removed).toEqual([`${deploymentConfig.cpanel.root}/releases/old`]);
|
|
});
|
|
|
|
it("does not prune when cPanel cannot provide reliable modification times", async () => {
|
|
const deploymentConfig = config({ retainCount: 2 });
|
|
const client = {
|
|
list: async () => [{ file: "active", mtime: 50 }, { file: "unknown-age" }],
|
|
remove: vi.fn(),
|
|
};
|
|
|
|
await expect(pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist"])).resolves.toEqual([]);
|
|
expect(client.remove).not.toHaveBeenCalled();
|
|
});
|
|
});
|