918 lines
33 KiB
JavaScript
918 lines
33 KiB
JavaScript
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import {
|
||
mkdtemp,
|
||
mkdir,
|
||
writeFile,
|
||
readFile,
|
||
cp,
|
||
symlink,
|
||
rm,
|
||
stat,
|
||
lstat,
|
||
realpath,
|
||
} from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import { spawnSync } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import { gzipSync } from "node:zlib";
|
||
import { fileURLToPath } from "node:url";
|
||
import { packageStandalone } from "../scripts/package-standalone.mjs";
|
||
import {
|
||
LOCKED_RUNTIME,
|
||
packageLockVersions,
|
||
} from "../scripts/check-lock-sync.mjs";
|
||
const projectRoot = new URL("../", import.meta.url);
|
||
async function fixture(t) {
|
||
const root = await mkdtemp(join(tmpdir(), "91-release-"));
|
||
t.after(() => rm(root, { recursive: true, force: true }));
|
||
async function put(path, text = "fixture") {
|
||
await mkdir(join(root, path, ".."), { recursive: true });
|
||
await writeFile(join(root, path), text);
|
||
}
|
||
for (const [env, build] of [
|
||
["test", ".next-test"],
|
||
["production", ".next"],
|
||
]) {
|
||
await put(`${build}/standalone/server.js`, "module.exports=true;");
|
||
await put(`${build}/standalone/${build}/BUILD_ID`, "build-" + env);
|
||
await put(`${build}/standalone/${build}/cache/seo/private.json`);
|
||
await put(`${build}/standalone/.env.local`, "SENSITIVE=do-not-ship");
|
||
await put(`${build}/standalone/config/secrets/private-key`);
|
||
await put(
|
||
`${build}/standalone/dist/production/config/production.local.env`,
|
||
);
|
||
await put(`${build}/standalone/tests/fixtures/private.json`);
|
||
await put(
|
||
`${build}/standalone/${build === ".next" ? ".next-test" : ".next"}/cache/seo/old.json`,
|
||
);
|
||
await put(`${build}/static/chunks/app.js`);
|
||
await put(
|
||
`config/${env}.env`,
|
||
`API_BASE_URL=https://api.test\nPORT=21030\n`,
|
||
);
|
||
await put(`config/${env}.local.env`, "SENSITIVE=do-not-ship");
|
||
}
|
||
await put("public/assets/logo.svg");
|
||
await put("public/._logo.svg");
|
||
// Mirror runtime lock versions so deploy check_local accepts npm or pnpm-style installs.
|
||
const versions = packageLockVersions(fileURLToPath(projectRoot));
|
||
await put(
|
||
"package.json",
|
||
JSON.stringify(
|
||
{
|
||
name: "jha-1050-jgdj-web",
|
||
private: true,
|
||
dependencies: Object.fromEntries(
|
||
LOCKED_RUNTIME.map((name) => [name, versions[name]]),
|
||
),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
await put(
|
||
"package-lock.json",
|
||
JSON.stringify(
|
||
{
|
||
lockfileVersion: 3,
|
||
packages: Object.fromEntries(
|
||
LOCKED_RUNTIME.map((name) => [
|
||
`node_modules/${name}`,
|
||
{ version: versions[name] },
|
||
]),
|
||
),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
for (const name of LOCKED_RUNTIME) {
|
||
await put(
|
||
`node_modules/${name}/package.json`,
|
||
JSON.stringify({ name, version: versions[name] }),
|
||
);
|
||
}
|
||
await cp(new URL("scripts", projectRoot), join(root, "scripts"), {
|
||
recursive: true,
|
||
});
|
||
// pnpm links dotenv; copy the real package so the fixture mirrors a concrete release tree.
|
||
await cp(
|
||
await realpath(new URL("node_modules/dotenv", projectRoot)),
|
||
join(root, "node_modules/dotenv"),
|
||
{ recursive: true, dereference: true },
|
||
);
|
||
return { root, put };
|
||
}
|
||
|
||
test("standalone releases include only their profile and assets, exclude local secrets/cache, and preserve other releases", async (t) => {
|
||
const { root, put } = await fixture(t);
|
||
for (const [env, build] of [
|
||
["test", ".next-test"],
|
||
["production", ".next"],
|
||
]) {
|
||
const release = await packageStandalone(env, { root });
|
||
for (const file of [
|
||
"start.mjs",
|
||
"server.js",
|
||
"package-lock.json",
|
||
"node_modules/dotenv/package.json",
|
||
"scripts/check-health.mjs",
|
||
build + "/BUILD_ID",
|
||
build + "/static/chunks/app.js",
|
||
"public/assets/logo.svg",
|
||
`config/${env}.env`,
|
||
])
|
||
assert.ok((await stat(join(release, file))).isFile(), file);
|
||
for (const file of [
|
||
".env.local",
|
||
"public/._logo.svg",
|
||
`config/${env}.local.env`,
|
||
"config/secrets",
|
||
"dist",
|
||
"tests",
|
||
build + "/cache",
|
||
build === ".next" ? ".next-test" : ".next",
|
||
])
|
||
await assert.rejects(stat(join(release, file)), { code: "ENOENT" });
|
||
const launch = spawnSync(process.execPath, [join(release, "start.mjs")], {
|
||
encoding: "utf8",
|
||
env: { PATH: process.env.PATH },
|
||
});
|
||
assert.equal(launch.status, 0, launch.stderr);
|
||
}
|
||
await put("dist/production/keep", "previous production");
|
||
await put("dist/test/stale", "remove stale file");
|
||
await packageStandalone("test", { root });
|
||
assert.equal(
|
||
await readFile(join(root, "dist/production/keep"), "utf8"),
|
||
"previous production",
|
||
);
|
||
await assert.rejects(stat(join(root, "dist/test/stale")), { code: "ENOENT" });
|
||
});
|
||
|
||
test("invalid environments and symlink destinations fail without deleting unrelated data", async (t) => {
|
||
const { root, put } = await fixture(t);
|
||
await put("outside/keep", "do not delete");
|
||
await mkdir(join(root, "dist"));
|
||
await symlink(join(root, "outside"), join(root, "dist/test"));
|
||
await assert.rejects(
|
||
packageStandalone("test", { root }),
|
||
/Unsafe release directory/,
|
||
);
|
||
await assert.rejects(packageStandalone("../outside", { root }), /Usage/);
|
||
assert.equal(
|
||
await readFile(join(root, "outside/keep"), "utf8"),
|
||
"do not delete",
|
||
);
|
||
await rm(join(root, "dist"), { recursive: true });
|
||
await symlink(join(root, "outside"), join(root, "dist"));
|
||
await assert.rejects(
|
||
packageStandalone("production", { root }),
|
||
/Unsafe release directory/,
|
||
);
|
||
assert.equal(
|
||
await readFile(join(root, "outside/keep"), "utf8"),
|
||
"do not delete",
|
||
);
|
||
});
|
||
|
||
test("in-tree pnpm-style module links are preserved while escaping links are rejected", async (t) => {
|
||
const { root, put } = await fixture(t);
|
||
const modules = join(root, ".next-test/standalone/node_modules");
|
||
await put(
|
||
".next-test/standalone/node_modules/.pnpm/pkg@1/node_modules/pkg/index.js",
|
||
"module.exports=1;",
|
||
);
|
||
await mkdir(join(modules, ".pnpm/node_modules"), { recursive: true });
|
||
await symlink(
|
||
"../pkg@1/node_modules/pkg",
|
||
join(modules, ".pnpm/node_modules/pkg"),
|
||
);
|
||
await symlink(".pnpm/pkg@1/node_modules/pkg", join(modules, "pkg"));
|
||
const release = await packageStandalone("test", { root });
|
||
const link = join(release, "node_modules/pkg");
|
||
assert.ok((await lstat(link)).isSymbolicLink());
|
||
assert.equal(
|
||
await readFile(join(link, "index.js"), "utf8"),
|
||
"module.exports=1;",
|
||
);
|
||
const outside = await mkdtemp(join(tmpdir(), "91-escape-"));
|
||
t.after(() => rm(outside, { recursive: true, force: true }));
|
||
await writeFile(join(outside, "secret"), "escape");
|
||
await mkdir(join(root, ".next/standalone/node_modules"), {
|
||
recursive: true,
|
||
});
|
||
await symlink(
|
||
join(outside, "secret"),
|
||
join(root, ".next/standalone/node_modules/escape-link"),
|
||
);
|
||
await assert.rejects(
|
||
packageStandalone("production", { root }),
|
||
/Release source contains a symbolic link/,
|
||
);
|
||
});
|
||
|
||
test("dry runs reflect current configuration, allow overrides and never build/upload; invalid deployment targets fail early", async (t) => {
|
||
const dir = await mkdtemp(join(tmpdir(), "91-deploy-stubs-"));
|
||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||
const marker = join(dir, "executed");
|
||
for (const command of ["npm", "ssh", "scp"])
|
||
await writeFile(
|
||
join(dir, command),
|
||
`#!/bin/bash\nprintf '%s\\n' '${command}' >> "$MARKER"\nexit 99\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
const env = {
|
||
...process.env,
|
||
PATH: dir + ":" + process.env.PATH,
|
||
MARKER: marker,
|
||
DEPLOY_DRY_RUN: "0",
|
||
};
|
||
for (const key of [
|
||
"DEPLOY_HOST",
|
||
"DEPLOY_DIR",
|
||
"DEPLOY_RUN_DIR",
|
||
"DEPLOY_JUMP_HOST",
|
||
"DEPLOY_JUMP_DIR",
|
||
"DEPLOY_SSH_PORT",
|
||
"DEPLOY_SCP_PORT",
|
||
"DEPLOY_CHECK_URL",
|
||
"DEPLOY_PROGRAM",
|
||
"DEPLOY_PACKAGE_NAME",
|
||
"DEPLOY_TARGET_ARCH",
|
||
"DEPLOY_TARGET_LIBC",
|
||
])
|
||
delete env[key];
|
||
// Deployment addresses/ports are operator configuration, not application invariants.
|
||
// Verify reporting consistency and explicit overrides rather than pinning a server.
|
||
for (const script of ["deploy_test.sh", "deploy_prod.sh"]) {
|
||
for (const args of [["--dry-run"], ["--package-only", "--dry-run"]]) {
|
||
const result = spawnSync("bash", [script, ...args], {
|
||
encoding: "utf8",
|
||
env,
|
||
});
|
||
assert.equal(result.status, 0, result.stderr);
|
||
assert.match(result.stdout, /API=(?:current|https?:\/\/\S+)\s+PORT=\d+/);
|
||
assert.doesNotMatch(result.stdout, /undefined/);
|
||
if (!args.includes("--package-only")) {
|
||
const help = spawnSync("bash", [script, "--help"], {
|
||
encoding: "utf8",
|
||
env,
|
||
});
|
||
assert.equal(help.status, 0, help.stderr);
|
||
if (script === "deploy_test.sh") {
|
||
assert.match(
|
||
result.stdout,
|
||
/上传 \S+\.tar\.gz、\S+\.tar\.gz\.sha256 和 up_next_h5\.sh/,
|
||
);
|
||
assert.doesNotMatch(result.stdout, /dist\.zip/);
|
||
const target = result.stdout.match(
|
||
/发布目标:(\S+):(\/[^(\n]+)(SSH (\d+))。/,
|
||
);
|
||
assert.ok(target, `${script} 未报告有效上传目标:${result.stdout}`);
|
||
const [, host, directory, port] = target;
|
||
assert.ok(Number(port) > 0 && Number(port) <= 65535);
|
||
assert.ok(
|
||
help.stdout.includes(
|
||
`${host}:${port} ${directory.replace(/\/$/, "")}/`,
|
||
),
|
||
help.stdout,
|
||
);
|
||
const execute = result.stdout.match(
|
||
/上传成功后:ssh -p (\d+) (\S+) "cd (\/\S+) && bash run\.sh"/,
|
||
);
|
||
assert.ok(execute, result.stdout);
|
||
assert.equal(execute[1], port);
|
||
assert.equal(execute[2], host);
|
||
assert.ok(
|
||
help.stdout.includes(`DEPLOY_RUN_DIR(${execute[3]})`),
|
||
help.stdout,
|
||
);
|
||
} else {
|
||
const upload = result.stdout.match(
|
||
/^\s+scp -P (\d+) (\S+) (\S+) ([^:\s]+):(\/\S*)$/m,
|
||
);
|
||
const execute = result.stdout.match(
|
||
/^\s+ssh -p (\d+) (\S+) "cd (\/\S+) && bash rsupdate\.sh"$/m,
|
||
);
|
||
assert.ok(upload, `${script} 未报告有效上传命令:${result.stdout}`);
|
||
assert.ok(execute, `${script} 未报告有效执行命令:${result.stdout}`);
|
||
const [, scpPort, archive, checksum, host, directory] = upload;
|
||
const [, sshPort, sshHost, runDirectory] = execute;
|
||
assert.equal(sshHost, host);
|
||
assert.equal(runDirectory, directory.replace(/\/$/, ""));
|
||
assert.equal(checksum, archive.replace(/\.tar\.gz$/, ".md5"));
|
||
assert.ok(help.stdout.includes(`${host}:${directory}`), help.stdout);
|
||
for (const [key, value] of [
|
||
["DEPLOY_SCP_PORT", scpPort],
|
||
["DEPLOY_SSH_PORT", sshPort],
|
||
["DEPLOY_PACKAGE_NAME", archive],
|
||
])
|
||
assert.ok(help.stdout.includes(`${key}(${value})`), help.stdout);
|
||
}
|
||
}
|
||
}
|
||
const customEnv = {
|
||
...env,
|
||
DEPLOY_HOST: "user@custom-test",
|
||
DEPLOY_DIR: "/srv/custom-test",
|
||
DEPLOY_RUN_DIR: "/srv/custom-run",
|
||
DEPLOY_JUMP_HOST: "user@custom-prod",
|
||
DEPLOY_JUMP_DIR: "/srv/custom-prod/",
|
||
DEPLOY_SSH_PORT: "2202",
|
||
DEPLOY_SCP_PORT: "2203",
|
||
DEPLOY_PACKAGE_NAME: "custom_package.tar.gz",
|
||
};
|
||
const custom = spawnSync("bash", [script, "--dry-run"], {
|
||
encoding: "utf8",
|
||
env: customEnv,
|
||
});
|
||
assert.equal(custom.status, 0, custom.stderr);
|
||
assert.ok(
|
||
custom.stdout.includes(
|
||
script === "deploy_test.sh"
|
||
? "user@custom-test:/srv/custom-test"
|
||
: "user@custom-prod:/srv/custom-prod/",
|
||
),
|
||
);
|
||
assert.ok(custom.stdout.includes("2202"));
|
||
if (script === "deploy_prod.sh") assert.ok(custom.stdout.includes("2203"));
|
||
assert.ok(custom.stdout.includes("custom_package.tar.gz"));
|
||
if (script === "deploy_test.sh")
|
||
assert.ok(custom.stdout.includes("custom_package.tar.gz.sha256"));
|
||
const customHelp = spawnSync("bash", [script, "--help"], {
|
||
encoding: "utf8",
|
||
env: customEnv,
|
||
});
|
||
assert.equal(customHelp.status, 0, customHelp.stderr);
|
||
assert.ok(customHelp.stdout.includes("2202"));
|
||
assert.ok(
|
||
customHelp.stdout.includes(
|
||
script === "deploy_test.sh"
|
||
? "/srv/custom-run"
|
||
: "custom_package.tar.gz",
|
||
),
|
||
);
|
||
if (script === "deploy_test.sh")
|
||
assert.match(
|
||
custom.stdout,
|
||
/ssh -p 2202 user@custom-test "cd \/srv\/custom-run && bash run\.sh"/,
|
||
);
|
||
else
|
||
assert.match(
|
||
custom.stdout,
|
||
/ssh -p 2202 user@custom-prod "cd \/srv\/custom-prod && bash rsupdate\.sh"/,
|
||
);
|
||
assert.ok(customHelp.stdout.includes("custom_package.tar.gz"));
|
||
const invalidArchive = spawnSync("bash", [script, "--package-only"], {
|
||
encoding: "utf8",
|
||
env: { ...env, DEPLOY_PACKAGE_NAME: "../invalid.zip" },
|
||
});
|
||
assert.notEqual(invalidArchive.status, 0);
|
||
assert.match(invalidArchive.stderr, /\.tar\.gz 文件名/);
|
||
const invalidHost = spawnSync("bash", [script], {
|
||
encoding: "utf8",
|
||
env: { ...env, DEPLOY_HOST: "bad;host", DEPLOY_JUMP_HOST: "bad;host" },
|
||
});
|
||
assert.notEqual(invalidHost.status, 0);
|
||
assert.match(invalidHost.stderr, /格式无效/);
|
||
const invalid = spawnSync("bash", [script], {
|
||
encoding: "utf8",
|
||
env: {
|
||
...env,
|
||
DEPLOY_HOST: "user@server",
|
||
DEPLOY_DIR: "/srv/../other",
|
||
DEPLOY_JUMP_HOST: "user@server",
|
||
DEPLOY_JUMP_DIR: "/srv/../other",
|
||
},
|
||
});
|
||
assert.notEqual(invalid.status, 0);
|
||
assert.match(invalid.stderr, /绝对路径/);
|
||
const invalidPort = spawnSync("bash", [script], {
|
||
encoding: "utf8",
|
||
env: { ...env, DEPLOY_SSH_PORT: "0" },
|
||
});
|
||
assert.notEqual(invalidPort.status, 0);
|
||
assert.match(invalidPort.stderr, /1–65535/);
|
||
if (script === "deploy_test.sh") {
|
||
const invalidRun = spawnSync("bash", [script], {
|
||
encoding: "utf8",
|
||
env: { ...env, DEPLOY_RUN_DIR: "/srv/../other" },
|
||
});
|
||
assert.notEqual(invalidRun.status, 0);
|
||
assert.match(invalidRun.stderr, /DEPLOY_RUN_DIR 必须是普通绝对路径/);
|
||
}
|
||
}
|
||
await assert.rejects(stat(marker), { code: "ENOENT" });
|
||
});
|
||
|
||
test("test deployment uploads tar.gz and checksum before run.sh, preserves its execution directory, and stops on failure", async (t) => {
|
||
const { root } = await fixture(t),
|
||
bin = join(root, "bin"),
|
||
marker = join(root, "commands.jsonl");
|
||
await mkdir(bin);
|
||
await mkdir(join(root, "deploy"));
|
||
await cp(
|
||
new URL("deploy_test.sh", projectRoot),
|
||
join(root, "deploy_test.sh"),
|
||
);
|
||
await cp(
|
||
new URL("deploy/up_next_h5.sh", projectRoot),
|
||
join(root, "deploy/up_next_h5.sh"),
|
||
);
|
||
for (const name of ["npm", "ssh", "scp"]) {
|
||
const code =
|
||
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args,testEnvironment:process.env.TEST_APP_ENV})+'\\n');` +
|
||
(name === "npm"
|
||
? `if(args.join(' ')==='run bundle:test'){const r=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','test'],{stdio:'inherit'});process.exit(r.status??1);}`
|
||
: name === "scp"
|
||
? `process.exit(Number(process.env.SCP_EXIT||0));`
|
||
: `if(args.at(-1).includes('bash run.sh'))process.exit(Number(process.env.RUN_EXIT||0));`);
|
||
await writeFile(
|
||
join(bin, name),
|
||
"#!" + process.execPath + "\n" + code + "\n",
|
||
{ mode: 0o755 },
|
||
);
|
||
}
|
||
const archive = "fixture_test.tar.gz";
|
||
const env = {
|
||
...process.env,
|
||
PATH: bin + ":" + process.env.PATH,
|
||
MARKER: marker,
|
||
DEPLOY_DRY_RUN: "0",
|
||
DEPLOY_HOST: "fixture-host",
|
||
DEPLOY_DIR: "/srv/fixture",
|
||
DEPLOY_SSH_PORT: "2202",
|
||
DEPLOY_PACKAGE_NAME: archive,
|
||
DEPLOY_CHECK_URL: "",
|
||
SCP_EXIT: "0",
|
||
RUN_EXIT: "0",
|
||
};
|
||
delete env.DEPLOY_RUN_DIR;
|
||
const run = (options = {}, args = []) =>
|
||
spawnSync("bash", ["deploy_test.sh", ...args], {
|
||
cwd: root,
|
||
encoding: "utf8",
|
||
env: { ...env, ...options },
|
||
});
|
||
const commands = async () =>
|
||
(await readFile(marker, "utf8"))
|
||
.trim()
|
||
.split("\n")
|
||
.filter(Boolean)
|
||
.map((line) => JSON.parse(line));
|
||
// Server directories are operator configuration. Compare actual execution with
|
||
// the script's advertised default rather than pinning a historical path.
|
||
const defaultRunDirectory = () => {
|
||
const help = run({}, ["--help"]);
|
||
assert.equal(help.status, 0, help.stderr);
|
||
const configured = help.stdout.match(/DEPLOY_RUN_DIR(([^)]+))/);
|
||
assert.ok(configured, help.stdout);
|
||
return configured[1];
|
||
};
|
||
const runDirectory = defaultRunDirectory();
|
||
const result = run();
|
||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||
const calls = await commands();
|
||
const npm = calls.filter((call) => call.command === "npm");
|
||
assert.deepEqual(
|
||
npm.map((call) => call.args),
|
||
[["run", "build:test"], ["test"], ["run", "bundle:test"]],
|
||
);
|
||
assert.equal(npm[1].testEnvironment, "test");
|
||
assert.deepEqual(
|
||
calls.filter((call) => call.command === "scp").map((call) => call.args),
|
||
[
|
||
[
|
||
"-P",
|
||
"2202",
|
||
archive,
|
||
archive + ".sha256",
|
||
"deploy/up_next_h5.sh",
|
||
"fixture-host:/srv/fixture/",
|
||
],
|
||
],
|
||
);
|
||
assert.deepEqual(
|
||
calls.filter((call) => call.command === "ssh").map((call) => call.args),
|
||
[
|
||
["-p", "2202", "fixture-host", "mkdir -p '/srv/fixture'"],
|
||
["-p", "2202", "fixture-host", `cd '${runDirectory}' && bash run.sh`],
|
||
],
|
||
);
|
||
assert.deepEqual(
|
||
calls.map((call) => call.command),
|
||
["npm", "npm", "npm", "ssh", "scp", "ssh"],
|
||
);
|
||
assert.match(result.stdout, /测试服 run\.sh 执行完成/);
|
||
const bytes = await readFile(join(root, archive));
|
||
assert.equal(bytes.subarray(0, 2).toString("hex"), "1f8b");
|
||
assert.equal(
|
||
await readFile(join(root, archive + ".sha256"), "utf8"),
|
||
createHash("sha256").update(bytes).digest("hex") + " " + archive + "\n",
|
||
);
|
||
assert.equal(
|
||
await readFile(join(root, archive.replace(/\.tar\.gz$/, ".md5")), "utf8"),
|
||
createHash("md5").update(bytes).digest("hex") + "\n",
|
||
);
|
||
const contents = spawnSync("tar", ["-tzf", join(root, archive)], {
|
||
encoding: "utf8",
|
||
});
|
||
assert.equal(contents.status, 0, contents.stderr);
|
||
assert.match(contents.stdout, /\.next-test\/BUILD_ID/);
|
||
assert.match(contents.stdout, /config\/test\.env/);
|
||
assert.doesNotMatch(contents.stdout, /node_modules|\.local\.env|\/cache\//);
|
||
for (const [name, options, args, status] of [
|
||
["custom-run-directory", { DEPLOY_RUN_DIR: "/srv/custom-run" }, [], 0],
|
||
["upload-failed", { SCP_EXIT: "23" }, [], 23],
|
||
["run-failed", { RUN_EXIT: "42" }, [], 42],
|
||
["package-only", {}, ["--package-only"], 0],
|
||
])
|
||
await t.test(name, async () => {
|
||
await writeFile(marker, "");
|
||
const result = run(options, args);
|
||
assert.equal(result.status, status, result.stdout + result.stderr);
|
||
const calls = await commands(),
|
||
ssh = calls.filter((call) => call.command === "ssh");
|
||
if (name === "package-only")
|
||
assert.ok(!calls.some((call) => ["ssh", "scp"].includes(call.command)));
|
||
else if (name === "upload-failed") {
|
||
assert.equal(ssh.length, 1);
|
||
assert.doesNotMatch(ssh[0].args.at(-1), /run\.sh/);
|
||
assert.doesNotMatch(result.stdout, /上传完成|执行完成/);
|
||
} else {
|
||
assert.equal(ssh.length, 2);
|
||
assert.equal(calls.at(-1).command, "ssh");
|
||
assert.equal(
|
||
ssh[1].args.at(-1),
|
||
`cd '${options.DEPLOY_RUN_DIR || runDirectory}' && bash run.sh`,
|
||
);
|
||
if (name === "run-failed")
|
||
assert.doesNotMatch(result.stdout, /执行完成/);
|
||
}
|
||
});
|
||
await t.test(
|
||
"changing the configured default directory does not require updating path assertions",
|
||
async () => {
|
||
const script = join(root, "deploy_test.sh"),
|
||
original = await readFile(script, "utf8");
|
||
const changed = original.replace(
|
||
/^RUN_DIR=.*$/m,
|
||
'RUN_DIR="${DEPLOY_RUN_DIR:-/srv/operator-default}"',
|
||
);
|
||
assert.notEqual(changed, original);
|
||
try {
|
||
await writeFile(script, changed);
|
||
await writeFile(marker, "");
|
||
const directory = defaultRunDirectory();
|
||
assert.equal(directory, "/srv/operator-default");
|
||
const result = run();
|
||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||
const calls = await commands();
|
||
assert.equal(calls.at(-1).command, "ssh");
|
||
assert.equal(
|
||
calls.at(-1).args.at(-1),
|
||
`cd '${directory}' && bash run.sh`,
|
||
);
|
||
} finally {
|
||
await writeFile(script, original);
|
||
}
|
||
},
|
||
);
|
||
});
|
||
|
||
test("production uploads before running the exact allowed SSH command, and stops on failure", async (t) => {
|
||
const { root } = await fixture(t),
|
||
bin = join(root, "bin"),
|
||
marker = join(root, "production-commands.jsonl");
|
||
await mkdir(bin);
|
||
await cp(
|
||
new URL("deploy_prod.sh", projectRoot),
|
||
join(root, "deploy_prod.sh"),
|
||
);
|
||
for (const name of ["npm", "ssh", "scp"]) {
|
||
const code =
|
||
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args})+'\\n');` +
|
||
(name === "npm"
|
||
? `if(args.join(' ')==='run bundle'){const result=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','production'],{stdio:'inherit'});process.exit(result.status??1);}if(args[1]==='bundle:linux'){const destination='dist/production-linux-x64-glibc';fs.rmSync(destination,{recursive:true,force:true});fs.cpSync('dist/production',destination,{recursive:true});fs.writeFileSync(destination+'/runtime-target.json',JSON.stringify({platform:'linux',arch:'x64',libc:'glibc'}));}`
|
||
: name === "scp"
|
||
? `process.exit(Number(process.env.SCP_EXIT||0));`
|
||
: `if(args.at(-1)!==process.env.ALLOWED_REMOTE_COMMAND){console.error('权限不足');process.exit(1);}process.stdout.write(process.env.RUN_OUTPUT||'');process.exit(Number(process.env.RUN_EXIT||0));`);
|
||
await writeFile(
|
||
join(bin, name),
|
||
"#!" + process.execPath + "\n" + code + "\n",
|
||
{ mode: 0o755 },
|
||
);
|
||
}
|
||
const archive = "fixture_production.tar.gz";
|
||
const env = {
|
||
...process.env,
|
||
PATH: bin + ":" + process.env.PATH,
|
||
MARKER: marker,
|
||
DEPLOY_DRY_RUN: "0",
|
||
DEPLOY_JUMP_HOST: "fixture-host",
|
||
DEPLOY_JUMP_DIR: "/srv/production/",
|
||
DEPLOY_SCP_PORT: "2203",
|
||
DEPLOY_SSH_PORT: "2202",
|
||
DEPLOY_TARGET_ARCH: "x64",
|
||
DEPLOY_TARGET_LIBC: "glibc",
|
||
DEPLOY_PACKAGE_NAME: archive,
|
||
ALLOWED_REMOTE_COMMAND: "cd /srv/production && bash rsupdate.sh",
|
||
SCP_EXIT: "0",
|
||
RUN_EXIT: "0",
|
||
RUN_OUTPUT: "",
|
||
};
|
||
const run = (options = {}, args = []) =>
|
||
spawnSync("bash", ["deploy_prod.sh", ...args], {
|
||
cwd: root,
|
||
encoding: "utf8",
|
||
env: { ...env, ...options },
|
||
});
|
||
const commands = async () =>
|
||
(await readFile(marker, "utf8"))
|
||
.trim()
|
||
.split("\n")
|
||
.filter(Boolean)
|
||
.map((line) => JSON.parse(line));
|
||
const result = run();
|
||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||
const calls = await commands();
|
||
assert.deepEqual(
|
||
calls.map((call) => call.command),
|
||
["npm", "npm", "npm", "scp", "ssh"],
|
||
);
|
||
assert.deepEqual(
|
||
calls.filter((call) => call.command === "npm").map((call) => call.args),
|
||
[
|
||
["run", "check"],
|
||
["run", "bundle"],
|
||
["run", "bundle:linux", "--", "x64", "glibc"],
|
||
],
|
||
);
|
||
assert.deepEqual(calls.find((call) => call.command === "scp").args, [
|
||
"-P",
|
||
"2203",
|
||
archive,
|
||
"fixture_production.md5",
|
||
"fixture-host:/srv/production/",
|
||
]);
|
||
assert.deepEqual(calls.find((call) => call.command === "ssh").args, [
|
||
"-p",
|
||
"2202",
|
||
"fixture-host",
|
||
env.ALLOWED_REMOTE_COMMAND,
|
||
]);
|
||
assert.ok(result.stdout.includes("远端命令:" + env.ALLOWED_REMOTE_COMMAND));
|
||
assert.match(result.stdout, /正式服 rsupdate\.sh 执行完成/);
|
||
assert.equal(
|
||
await readFile(join(root, "fixture_production.md5"), "utf8"),
|
||
createHash("md5")
|
||
.update(await readFile(join(root, archive)))
|
||
.digest("hex") + "\n",
|
||
);
|
||
for (const [name, options, args, status] of [
|
||
["upload-failed", { SCP_EXIT: "23" }, [], 23],
|
||
["remote-failed", { RUN_EXIT: "42" }, [], 1],
|
||
[
|
||
"restricted-ssh-denied",
|
||
{ ALLOWED_REMOTE_COMMAND: "a different command" },
|
||
[],
|
||
1,
|
||
],
|
||
[
|
||
"remote-error-output",
|
||
{ RUN_OUTPUT: "FATAL: service did not start\n" },
|
||
[],
|
||
1,
|
||
],
|
||
[
|
||
"remote-curl-401",
|
||
{
|
||
RUN_OUTPUT:
|
||
"curl: (22) The requested URL returned error: 401\n发布成功\n",
|
||
},
|
||
[],
|
||
1,
|
||
],
|
||
[
|
||
"custom-directory",
|
||
{
|
||
DEPLOY_JUMP_DIR: "/srv/custom-production/",
|
||
ALLOWED_REMOTE_COMMAND: "cd /srv/custom-production && bash rsupdate.sh",
|
||
},
|
||
[],
|
||
0,
|
||
],
|
||
["package-only", {}, ["--package-only"], 0],
|
||
])
|
||
await t.test(name, async () => {
|
||
await writeFile(marker, "");
|
||
const result = run(options, args);
|
||
assert.equal(result.status, status, result.stdout + result.stderr);
|
||
const calls = await commands();
|
||
if (name === "package-only")
|
||
assert.ok(!calls.some((call) => ["scp", "ssh"].includes(call.command)));
|
||
else if (name === "upload-failed")
|
||
assert.ok(!calls.some((call) => call.command === "ssh"));
|
||
else assert.equal(calls.at(-1).command, "ssh");
|
||
if (name === "restricted-ssh-denied")
|
||
assert.match(result.stdout, /权限不足/);
|
||
if (name === "custom-directory") {
|
||
assert.equal(calls.at(-1).args.at(-1), options.ALLOWED_REMOTE_COMMAND);
|
||
assert.equal(
|
||
calls.find((call) => call.command === "scp").args.at(-1),
|
||
"fixture-host:/srv/custom-production/",
|
||
);
|
||
}
|
||
if (status) assert.doesNotMatch(result.stdout, /执行完成/);
|
||
});
|
||
});
|
||
|
||
test("tar.gz server updates reject invalid archives before stopping services and roll back unhealthy replacements", async (t) => {
|
||
for (const scenario of [
|
||
"checksum",
|
||
"archive",
|
||
"link",
|
||
"traversal",
|
||
"install",
|
||
"health",
|
||
"success",
|
||
"custom-name",
|
||
])
|
||
await t.test(scenario, async (t) => {
|
||
const root = await mkdtemp(join(tmpdir(), "91-server-update-"));
|
||
t.after(() => rm(root, { recursive: true, force: true }));
|
||
const bin = join(root, "bin"),
|
||
upload = join(root, "package");
|
||
await mkdir(bin);
|
||
await mkdir(upload);
|
||
async function put(base, path, text = "fixture") {
|
||
await mkdir(join(base, path, ".."), { recursive: true });
|
||
await writeFile(join(base, path), text);
|
||
}
|
||
await cp(
|
||
new URL("deploy/up_next_h5.sh", projectRoot),
|
||
join(root, "up_next_h5.sh"),
|
||
);
|
||
await put(root, "next/version", "previous");
|
||
await put(root, "next/config/test.local.env", "PORT=21022\n");
|
||
await put(
|
||
root,
|
||
"next/.next-test/cache/seo/keep.json",
|
||
"previous public SEO",
|
||
);
|
||
for (const file of [
|
||
"start.mjs",
|
||
"server.js",
|
||
".next-test/BUILD_ID",
|
||
".next-test/static/app.js",
|
||
"public/logo.svg",
|
||
"package-lock.json",
|
||
])
|
||
await put(upload, file);
|
||
await put(upload, "package.json", "{}");
|
||
await put(upload, "version", "replacement");
|
||
await put(
|
||
upload,
|
||
"config/test.env",
|
||
"API_BASE_URL=https://fixture.test\nPORT=21030\n",
|
||
);
|
||
await cp(
|
||
new URL("scripts/next-env.mjs", projectRoot),
|
||
join(upload, "scripts/next-env.mjs"),
|
||
);
|
||
await put(
|
||
upload,
|
||
"scripts/check-health.mjs",
|
||
"process.exit(Number(process.env.HEALTH_EXIT||0));",
|
||
);
|
||
const packageName =
|
||
scenario === "custom-name"
|
||
? "custom_release.tar.gz"
|
||
: "web_jgdj_h5.tar.gz";
|
||
if (scenario === "link")
|
||
await symlink(
|
||
join(root, "untouched"),
|
||
join(upload, "public/unsafe-link"),
|
||
);
|
||
const packed = spawnSync(
|
||
"tar",
|
||
["--no-xattrs", "-czf", join(root, packageName), "-C", upload, "."],
|
||
{ encoding: "utf8", env: { ...process.env, COPYFILE_DISABLE: "1" } },
|
||
);
|
||
assert.equal(packed.status, 0, packed.stderr);
|
||
if (scenario === "archive")
|
||
await writeFile(join(root, packageName), "not a gzip archive");
|
||
if (scenario === "traversal") {
|
||
const header = Buffer.alloc(512);
|
||
for (const [offset, value] of [
|
||
[0, "../escaped-file"],
|
||
[100, "0000644\0"],
|
||
[108, "0000000\0"],
|
||
[116, "0000000\0"],
|
||
[124, "00000000000\0"],
|
||
[136, "00000000000\0"],
|
||
[257, "ustar\0"],
|
||
[263, "00"],
|
||
])
|
||
header.write(value, offset);
|
||
header.fill(32, 148, 156);
|
||
header[156] = 48;
|
||
header.write(
|
||
[...header]
|
||
.reduce((total, byte) => total + byte, 0)
|
||
.toString(8)
|
||
.padStart(6, "0") + "\0 ",
|
||
148,
|
||
);
|
||
await writeFile(
|
||
join(root, packageName),
|
||
gzipSync(Buffer.concat([header, Buffer.alloc(1024)])),
|
||
);
|
||
}
|
||
const hash = createHash("sha256")
|
||
.update(await readFile(join(root, packageName)))
|
||
.digest("hex");
|
||
await writeFile(
|
||
join(root, packageName + ".sha256"),
|
||
(scenario === "checksum" ? "0".repeat(64) : hash) +
|
||
" " +
|
||
packageName +
|
||
"\n",
|
||
);
|
||
const stubs = {
|
||
npm: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,'npm\\n');if(process.env.INSTALL_FAIL==='1')process.exit(1);fs.mkdirSync('node_modules',{recursive:true});fs.cpSync(process.env.DOTENV_SOURCE,'node_modules/dotenv',{recursive:true});`,
|
||
supervisorctl: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,process.argv.slice(2).join(' ')+'\\n');if(process.argv[2]==='status')console.log('jgdj_next_h5 RUNNING pid 100');`,
|
||
sha256sum: `const fs=require('node:fs'),crypto=require('node:crypto');const expected=fs.readFileSync(process.argv[3],'utf8').split(/\\s/)[0],actual=crypto.createHash('sha256').update(fs.readFileSync(process.argv[3].replace(/\\.sha256$/,''))).digest('hex');process.exit(expected===actual?0:1);`,
|
||
};
|
||
for (const [name, code] of Object.entries(stubs))
|
||
await writeFile(
|
||
join(bin, name),
|
||
"#!" + process.execPath + "\n" + code + "\n",
|
||
{ mode: 0o755 },
|
||
);
|
||
const marker = join(root, "commands");
|
||
// The fake Supervisor has its own service; caller deployment overrides must not select a real service here.
|
||
const result = spawnSync("bash", [join(root, "up_next_h5.sh")], {
|
||
encoding: "utf8",
|
||
env: {
|
||
...process.env,
|
||
DEPLOY_PROGRAM: "jgdj_next_h5",
|
||
DEPLOY_PACKAGE_NAME: packageName,
|
||
PATH: bin + ":" + process.env.PATH,
|
||
MARKER: marker,
|
||
DOTENV_SOURCE: new URL("node_modules/dotenv", projectRoot).pathname,
|
||
INSTALL_FAIL: scenario === "install" ? "1" : "0",
|
||
HEALTH_EXIT: scenario === "health" ? "1" : "0",
|
||
},
|
||
});
|
||
const commands = await readFile(marker, "utf8");
|
||
if (["success", "custom-name"].includes(scenario)) {
|
||
assert.equal(result.status, 0, result.stderr);
|
||
assert.equal(
|
||
await readFile(join(root, "next/version"), "utf8"),
|
||
"replacement",
|
||
);
|
||
assert.equal(
|
||
await readFile(join(root, "next/config/test.local.env"), "utf8"),
|
||
"PORT=21022\n",
|
||
);
|
||
assert.equal(
|
||
await readFile(
|
||
join(root, "next/.next-test/cache/seo/keep.json"),
|
||
"utf8",
|
||
),
|
||
"previous public SEO",
|
||
);
|
||
} else {
|
||
assert.notEqual(result.status, 0);
|
||
assert.equal(
|
||
await readFile(join(root, "next/version"), "utf8"),
|
||
"previous",
|
||
);
|
||
if (scenario === "health")
|
||
assert.equal(
|
||
commands.match(/start jgdj_next_h5/g)?.length,
|
||
2,
|
||
commands,
|
||
);
|
||
else assert.doesNotMatch(commands, /stop |start /);
|
||
if (["checksum", "archive", "link", "traversal"].includes(scenario))
|
||
assert.doesNotMatch(commands, /npm/);
|
||
if (scenario === "archive")
|
||
assert.match(result.stderr, /tar.gz 压缩包损坏/);
|
||
if (scenario === "link") assert.match(result.stderr, /不允许链接/);
|
||
if (scenario === "traversal")
|
||
assert.match(result.stderr, /非法 tar.gz 路径/);
|
||
}
|
||
await assert.rejects(stat(join(root, "escaped-file")), {
|
||
code: "ENOENT",
|
||
});
|
||
await assert.rejects(stat(join(root, ".jgdj_next_h5.lock")), {
|
||
code: "ENOENT",
|
||
});
|
||
});
|
||
});
|