Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-11-05 23:07:59 +07:00
co-authored by Claude Opus 5.5
commit f65d10e998
377 changed files with 31301 additions and 0 deletions
+917
View File
@@ -0,0 +1,917 @@
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",
});
});
});