## Summary - Preserve lftp stdout/stderr when the process exits non-zero. - Surface bounded, whitespace-normalized diagnostics through the deploy error. - Redact FTPS host, username, password, path, URL userinfo, and encoded secret forms. ## Verification - `vitest run tests/unit/cpanel-deploy.spec.js` (25/25) - `node --check scripts/release/cpanel-deploy-lib.mjs` - `git diff --check` This is the prerequisite diagnostic repair for failed Frontend Release run 29854900889. Production was not switched during that failure.
810 lines
32 KiB
JavaScript
810 lines
32 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";
|
|
|
|
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("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(["-f", "/dev/stdin"]);
|
|
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(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();
|
|
});
|
|
});
|