Merge pull request 'build: 独立封装发布依赖并用写入时复制控制空间' (#3) from codex/production-48-runtime-snapshot into main

This commit is contained in:
WuYongJie 2026-10-30 18:43:39 +08:00
commit 10efb4c1e7
4 changed files with 196 additions and 72 deletions

View File

@ -18,7 +18,7 @@
## 操作入口
维护电脑需要 Python 3.9+、Paramiko、Git,以及已信任的 SSH `known_hosts`。
维护电脑需要 Python 3.9+、Paramiko 3.3+、Git,以及已信任的 SSH `known_hosts`。
默认使用 `~/.ssh/45.192.99.48.pem` 和跳板密钥 `~/.ssh/youyidian_prod_ed25519`。
可用 `--key`、`--jump-key`、`--known-hosts` 覆盖本机路径,不要把私钥提交到仓库。
@ -54,7 +54,7 @@ python scripts/production48.py rollback --release <发布ID>
```
`stage` 校验传输 SHA256,在 `/var/www/ai-chatbot-releases/<发布ID>` 解包。
它检查压缩包路径、磁盘容量、锁文件、实际依赖包清单和共享 public 文件校验和。
它检查压缩包路径、磁盘容量、锁文件、实际依赖文件/链接清单和 public 文件校验和。
完成解包后至少保留 1 GiB 空间;不足时停止,不自动删除历史构建。
当前和旧浏览器标签页所需的静态资源通过硬链接保留,不改动旧构建。
@ -64,13 +64,19 @@ python scripts/production48.py rollback --release <发布ID>
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
## 共享运行资料与边界
## 运行资料与边界
初版发布复用原目录 `/var/www/ai-chatbot` 中的 `node_modules`、`public`、`.env.local`、
`tmp` 和模型配置备份,均通过链接使用,不复制、删除或覆盖。
生产依赖必须与构建依赖的校验清单相符,Node ABI 必须一致。
若本次修改了依赖锁文件或 public 中的文件,脚本会停止,须先单独审查并准备对应运行资料,
不能绕过校验强行切换。本流程适用于应用代码迭代;数据库、共享资源和原生 Agent 升级各自单独发布。
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。
节点之间不复用 node_modules 链接,避免服务器漏装依赖补丁却被误认为同一版本。
对内容完全相同的依赖文件,通过 XFS 的写入时复制创建独立 inode,复用底层数据块;
缺失或不同的文件从已校验制品写入。任何一份副本后续修改均不会改动另一份。
生产文件系统不支持该操作时停止,不自动回退成可能耗尽磁盘的完整复制。
public 先从当前运行版本创建写入时复制副本,保留未纳入 Git 的运行资源,再叠加本次源码资源。
完整文件和链接清单在切换前再次验证,Node ABI 必须一致,并在生产机测试 sharp 原生模块。
原目录 `/var/www/ai-chatbot` 中的 `.env.local`、`tmp` 和模型配置备份仍通过链接使用,
不会复制、删除或覆盖。新版本也不在生产机执行依赖安装或构建。
本流程适用于应用代码迭代;数据库、共享资源的删除/迁移和原生 Agent 升级各自单独发布。
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。

View File

@ -194,6 +194,7 @@ with open(workspace/'source/.env.production.local','x') as stream:
["python3", f"{workspace}/host.py", "smoke", "--workspace", workspace]), timeout=240))
write_json(local / "smoke.json", smoke)
print(json.dumps(smoke), flush=True)
print("[pack] packaging source, build, pinned dependencies and public assets", flush=True)
result = json.loads(connections.execute("build", shlex.join(
["python3", f"{workspace}/host.py", "pack", "--workspace", workspace]), timeout=600))
result.update({"builder": remote, "mainRef": commit,
@ -208,9 +209,12 @@ def stage(connections, args):
artifact = local / "artifact.tar.gz"
if not artifact.exists():
with connections.sftp("build") as transfer:
transfer.get(f"{BUILD_ROOT}/{args.release}/artifact.tar.gz", str(artifact))
transfer.get(f"{BUILD_ROOT}/{args.release}/artifact.tar.gz", str(artifact),
max_concurrent_prefetch_requests=32)
require(sha(artifact) == receipt["artifactSha256"], "Downloaded artifact checksum mismatch")
connections.helper("status")
state = connections.helper("status")
require(state["freeBytes"] > artifact.stat().st_size + 1024**3 + 64 * 1024**2,
"Insufficient space to upload artifact while retaining 1 GiB reserve")
connections.execute("prod", f"install -d -m 700 -- {RELEASE_ROOT}")
connections.upload("prod", artifact, f"{RELEASE_ROOT}/{args.release}.tar.gz")
result = connections.helper("stage", release=args.release, digest=receipt["artifactSha256"])

View File

@ -53,23 +53,27 @@ def json_write(path, value):
os.replace(temporary, path)
def validate_members(members, destination):
for member in members:
path = PurePosixPath(member.name)
require(not path.is_absolute() and ".." not in path.parts,
"Unsafe archive member")
require(member.isdir() or member.isfile() or member.issym(),
"Special files and hard links are not accepted")
if member.issym():
link = PurePosixPath(member.linkname)
require(not link.is_absolute(), "Absolute archive symlink")
target = (destination / path.parent / member.linkname).resolve()
require(target.is_relative_to(destination.resolve()),
"Archive symlink escapes destination")
def safe_extract(archive, destination):
"""Reject traversal, special files and links escaping a new directory."""
require(not destination.exists(), "Extraction destination already exists")
with tarfile.open(archive, "r:gz") as bundle:
members = bundle.getmembers()
for member in members:
path = PurePosixPath(member.name)
require(not path.is_absolute() and ".." not in path.parts,
"Unsafe archive member")
require(member.isdir() or member.isfile() or member.issym(),
"Special files and hard links are not accepted")
if member.issym():
link = PurePosixPath(member.linkname)
require(not link.is_absolute(), "Absolute archive symlink")
target = (destination / path.parent / member.linkname).resolve()
require(target.is_relative_to(destination.resolve()),
"Archive symlink escapes destination")
validate_members(members, destination)
needed = sum(m.size for m in members if m.isfile())
require(shutil.disk_usage(destination.parent).free > needed + RESERVE,
"Insufficient space: at least 1 GiB must remain after extraction")
@ -108,7 +112,7 @@ def app_state():
env = app["pm2_env"]
cwd = Path(env["pm_cwd"])
require(cwd == APP or cwd.parent == RELEASES, "Unexpected production cwd")
require(env["pm_exec_path"] == str(APP / "node_modules/next/dist/bin/next"),
require(env["pm_exec_path"] == str(cwd / "node_modules/next/dist/bin/next"),
"Unexpected Next.js executable")
require(env.get("watch") is False, "PM2 watch must be disabled")
require(env.get("CANVAS_E2E_LOCAL") != "true", "Production E2E mode is enabled")
@ -124,6 +128,7 @@ def status():
return {"hostname": socket.gethostname(), "pid": app["pid"],
"status": app["pm2_env"]["status"], "cwd": str(cwd), "distDir": dist,
"buildId": (cwd / dist / "BUILD_ID").read_text().strip(),
"freeBytes": shutil.disk_usage(APP).free,
"freeGiB": round(shutil.disk_usage(APP).free / 1024**3, 2),
"node": run(["node", "-v"]).strip()}
@ -169,40 +174,30 @@ const sql=require('postgres')(process.env.POSTGRES_URL,{max:1,connect_timeout:5,
return json.loads(run(["node", "-"], cwd=APP, stdin=source, timeout=50))
def inventory_packages(root):
result = {}
for path in sorted((root / "node_modules/.pnpm").glob("*/node_modules/*/package.json")):
result[path.relative_to(root / "node_modules").as_posix()] = sha(path)
for path in sorted((root / "node_modules/.pnpm").glob("*/node_modules/@*/*/package.json")):
result[path.relative_to(root / "node_modules").as_posix()] = sha(path)
return result
def build_manifest(workspace):
source = workspace / "source"
metadata = json.loads((workspace / "input.json").read_text())
result = {"schema": 1, "sourceCommit": metadata["sourceCommit"],
runtime_files, runtime_links = {}, {}
for name in ["public", "node_modules"]:
for path in sorted((source / name).rglob("*")):
relative = path.relative_to(source).as_posix()
if path.is_symlink():
runtime_links[relative] = os.readlink(path)
elif path.is_file():
runtime_files[relative] = {"sha256": sha(path), "bytes": path.stat().st_size}
result = {"schema": 2, "sourceCommit": metadata["sourceCommit"],
"sourceTree": metadata["sourceTree"], "releaseId": workspace.name,
"nodeMajor": 20, "nodeAbi": "115", "pnpm": "9.12.3",
"buildId": (source / ".next/BUILD_ID").read_text().strip(),
"lockSha256": sha(source / "pnpm-lock.yaml"),
"publicFiles": {p.relative_to(source).as_posix(): sha(p)
for p in sorted((source / "public").rglob("*")) if p.is_file()},
"directPackages": {name: sha(source / "node_modules" / name / "package.json")
for name in json.loads((source / "package.json").read_text())["dependencies"]},
"installedPackages": inventory_packages(source)}
require(result["installedPackages"], "No dependency inventory")
"runtimeFiles": runtime_files, "runtimeLinks": runtime_links}
require(runtime_files, "No runtime inventory")
json_write(source / "release.json", result)
with tarfile.open(workspace / "artifact.tar.gz", "w:gz", compresslevel=3) as archive:
for path in sorted(source.iterdir()):
if path.name in {"node_modules", "public", ".env.production.local", ".next"}:
continue
archive.add(path, arcname=path.name)
def filter_build(member):
if member.name == ".next/cache" or member.name.startswith(".next/cache/"):
return None
return member
archive.add(source / ".next", arcname=".next", filter=filter_build)
# Preserve relative package links, but materialize hard links as ordinary
# archive entries so the extractor never has to trust archive hard links.
run(["nice", "-n", "10", "tar", "--hard-dereference", "--exclude=./.next/cache",
"--exclude=./.env.production.local", "-czf", str(workspace / "artifact.tar.gz"),
"-C", str(source), "."], timeout=600)
return {"releaseId": workspace.name, "sourceCommit": result["sourceCommit"],
"buildId": result["buildId"], "artifactSha256": sha(workspace / "artifact.tar.gz"),
"artifactBytes": (workspace / "artifact.tar.gz").stat().st_size}
@ -247,37 +242,95 @@ def smoke(workspace):
process.wait()
def verify_shared(manifest):
require(sha(APP / "pnpm-lock.yaml") == manifest["lockSha256"],
"Dependencies changed: provision a separate reviewed runtime dependency set first")
for name, digest in manifest["publicFiles"].items():
path = PurePosixPath(name)
require(path.parts[0] == "public" and ".." not in path.parts,
"Invalid public path")
require((APP / name).is_file() and sha(APP / name) == digest,
f"Public files differ: {name}; review shared asset publication first")
actual = inventory_packages(APP)
missing = [name for name, value in manifest["installedPackages"].items()
if actual.get(name) != value]
require(not missing, f"Installed dependency mismatch ({len(missing)} packages)")
for name, digest in manifest["directPackages"].items():
require(sha(APP / "node_modules" / name / "package.json") == digest,
f"Direct dependency link mismatch: {name}")
def verify_runtime(manifest, root):
require(manifest["schema"] == 2, "Unsupported artifact schema")
require(sha(root / "pnpm-lock.yaml") == manifest["lockSha256"], "Release lock mismatch")
for name, metadata in manifest["runtimeFiles"].items():
require(sha(root / name) == metadata["sha256"], f"Runtime file mismatch: {name}")
for name, target in manifest["runtimeLinks"].items():
require((root / name).is_symlink() and os.readlink(root / name) == target,
f"Runtime link mismatch: {name}")
require(run(["node", "-p", "process.versions.modules"]).strip() == manifest["nodeAbi"],
"Node ABI mismatch")
def clone_file(source, destination):
"""Independent copy-on-write inode; never hard-link writable dependencies."""
import fcntl
with open(source, "rb") as origin, open(destination, "xb") as target:
# Linux FICLONE. Stop on unsupported filesystems, with no full-copy fallback.
fcntl.ioctl(target.fileno(), 0x40049409, origin.fileno())
def extract_runtime(archive, destination, current):
require(not destination.exists(), "Release destination already exists")
with tarfile.open(archive, "r:gz") as bundle:
members = bundle.getmembers()
validate_members(members, destination)
names = {m.name.removeprefix("./"): m for m in members}
manifest = json.load(bundle.extractfile(names["release.json"]))
require(manifest["schema"] == 2, "Unsupported artifact schema")
reusable, needed = set(), 0
for member in members:
if not member.isfile():
continue
name = member.name.removeprefix("./")
expected = manifest["runtimeFiles"].get(name)
source = current / name
if expected and source.is_file() and source.stat().st_size == member.size and sha(source) == expected["sha256"]:
reusable.add(name)
else:
needed += member.size
# Allow for inode/directory allocation as well as newly written file data.
require(shutil.disk_usage(destination.parent).free > needed + RESERVE + 64 * 1024**2,
"Insufficient space for release delta and 1 GiB reserve")
destination.mkdir(mode=0o700)
run(["cp", "-a", "--reflink=always", "--", str(current / "public"), str(destination / "public")], timeout=120)
for member in members:
if member.issym():
continue
name = member.name.removeprefix("./")
target = destination / name
require(target.parent.resolve().is_relative_to(destination.resolve()) or target == destination,
"Extraction parent escapes release")
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
target.unlink()
if name in reusable:
if not target.exists():
clone_file(current / name, target)
else:
with bundle.extractfile(member) as origin, open(target, "wb") as output:
shutil.copyfileobj(origin, output)
target.chmod(member.mode & 0o777)
for member in members:
if member.issym():
target = destination / member.name.removeprefix("./")
require(target.parent.resolve().is_relative_to(destination.resolve()), "Link parent escapes release")
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink() or target.exists():
require(not target.is_dir(), "Unexpected directory at link path")
target.unlink()
target.symlink_to(member.linkname)
require(shutil.disk_usage(destination).free > RESERVE, "Post-extraction disk reserve exhausted")
return manifest
def stage(release_id, digest):
_, current, dist = app_state()
archive = RELEASES / (release_id + ".tar.gz")
require(sha(archive) == digest, "Artifact checksum mismatch")
destination = release_path(release_id)
safe_extract(archive, destination)
manifest = json.loads((destination / "release.json").read_text())
manifest = extract_runtime(archive, destination, current)
require(manifest["releaseId"] == release_id, "Artifact release ID mismatch")
require(manifest["sourceCommit"][:12] == release_id[-12:], "Artifact source mismatch")
verify_shared(manifest)
for name in ["node_modules", "public", ".env.local", "tmp", ".model-config-backups"]:
verify_runtime(manifest, destination)
run(["node", "-e", "const s=require('sharp');s({create:{width:1,height:1,channels:3,background:'white'}}).png().toBuffer().then(b=>{if(!b.length)process.exit(1)})"],
cwd=destination)
for name in [".env.local", "tmp", ".model-config-backups"]:
require(not (destination / name).exists(), f"Unexpected bundled shared path: {name}")
if (APP / name).exists():
(destination / name).symlink_to(APP / name)
@ -303,7 +356,7 @@ def ecosystem(app, cwd, dist):
old = app["pm2_env"]
env = {k: v for k, v in old.items() if k.isupper() and isinstance(v, (str, int, bool))}
env.update({"NODE_ENV": "production", "PORT": "3125", "PWD": str(cwd), "NEXT_DIST_DIR": dist})
return {"apps": [{"name": NAME, "cwd": str(cwd), "script": old["pm_exec_path"],
return {"apps": [{"name": NAME, "cwd": str(cwd), "script": str(cwd / "node_modules/next/dist/bin/next"),
"args": old["args"], "interpreter": old.get("exec_interpreter", "/usr/bin/node"),
"exec_mode": "fork", "instances": 1, "watch": False, "env": env,
"autorestart": old.get("autorestart", True),
@ -342,7 +395,7 @@ def activate(release_id):
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
"Production changed after staging")
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
verify_shared(manifest)
verify_runtime(manifest, destination)
jobs = job_status()
require(not any(row["recent"] for rows in jobs.values() for row in rows),
"Recent active generation jobs: defer activation")

View File

@ -83,6 +83,67 @@ class ArchiveTests(unittest.TestCase):
self.assertFalse(self.destination.exists())
class RuntimeSnapshotTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name).resolve()
self.current = self.root / "current"
(self.current / "public").mkdir(parents=True)
(self.current / "node_modules/pkg").mkdir(parents=True)
(self.current / "public/asset.txt").write_bytes(b"old-public")
(self.current / "public/untracked-font.ttf").write_bytes(b"runtime-font")
(self.current / "node_modules/pkg/index.js").write_bytes(b"same-package")
self.destination = self.root / "release"
self.archive = self.root / "artifact.tar.gz"
data = {"public/asset.txt": b"new-public",
"node_modules/pkg/index.js": b"same-package",
"node_modules/patched/index.js": b"new-patch"}
manifest = {"schema": 2, "runtimeFiles": {
name: {"sha256": host.hashlib.sha256(body).hexdigest(), "bytes": len(body)}
for name, body in data.items()}, "runtimeLinks": {}}
data["release.json"] = json.dumps(manifest).encode()
with tarfile.open(self.archive, "w:gz") as bundle:
for name, body in data.items():
info = tarfile.TarInfo("./" + name)
info.size = len(body)
bundle.addfile(info, io.BytesIO(body))
def tearDown(self):
self.temporary.cleanup()
def copy_public(self, args, **kwargs):
self.assertEqual(args[:3], ["cp", "-a", "--reflink=always"])
shutil.copytree(args[-2], args[-1])
return ""
def test_snapshot_keeps_untracked_assets_and_applies_patched_dependencies(self):
with patch.object(host, "run", side_effect=self.copy_public), \
patch.object(host, "clone_file", side_effect=shutil.copyfile) as clone:
host.extract_runtime(self.archive, self.destination, self.current)
clone.assert_called_once()
self.assertEqual((self.destination / "public/untracked-font.ttf").read_bytes(), b"runtime-font")
self.assertEqual((self.destination / "public/asset.txt").read_bytes(), b"new-public")
self.assertEqual((self.current / "public/asset.txt").read_bytes(), b"old-public")
self.assertEqual((self.destination / "node_modules/patched/index.js").read_bytes(), b"new-patch")
(self.destination / "node_modules/pkg/index.js").write_bytes(b"changed")
self.assertEqual((self.current / "node_modules/pkg/index.js").read_bytes(), b"same-package")
def test_space_guard_runs_before_copying_or_mutating(self):
with patch.object(host.shutil, "disk_usage", return_value=shutil._ntuple_diskusage(10, 9, 1)), \
patch.object(host, "run") as copy:
with self.assertRaisesRegex(RuntimeError, "Insufficient space"):
host.extract_runtime(self.archive, self.destination, self.current)
copy.assert_not_called()
self.assertFalse(self.destination.exists())
def test_unsupported_clone_stops_without_falling_back_to_full_copy(self):
with patch.object(host, "run", side_effect=self.copy_public), \
patch.object(host, "clone_file", side_effect=OSError("unsupported")):
with self.assertRaisesRegex(OSError, "unsupported"):
host.extract_runtime(self.archive, self.destination, self.current)
self.assertEqual((self.current / "node_modules/pkg/index.js").read_bytes(), b"same-package")
class ActivationTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
@ -105,7 +166,7 @@ class ActivationTests(unittest.TestCase):
"CANVAS_ZCODE_STATE_DIR": "/private/state", "NEXT_DIST_DIR": ".next-old",
"pm_err_log_path": "/logs/error", "pm_out_log_path": "/logs/out"}}
self.patches = [patch.object(host, "RELEASES", self.releases),
patch.object(host, "verify_shared"),
patch.object(host, "verify_runtime"),
patch.object(host, "app_state", return_value=(self.app, self.previous, ".next-old")),
patch.object(host.Path, "home", return_value=self.root),
patch.object(host, "run", return_value=""),