Compare commits

..

No commits in common. "main" and "codex/production-48-release-pipeline" have entirely different histories.

4 changed files with 78 additions and 289 deletions

View File

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

View File

@ -194,7 +194,6 @@ 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,
@ -209,12 +208,9 @@ 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),
max_concurrent_prefetch_requests=32)
transfer.get(f"{BUILD_ROOT}/{args.release}/artifact.tar.gz", str(artifact))
require(sha(artifact) == receipt["artifactSha256"], "Downloaded artifact checksum mismatch")
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.helper("status")
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"])
@ -226,13 +222,11 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["status", "jobs", "build", "stage", "activate", "rollback"])
parser.add_argument("--release")
parser.add_argument("--retry-rolled-back", action="store_true")
parser.add_argument("--key", type=Path, default=Path.home() / ".ssh/45.192.99.48.pem")
parser.add_argument("--jump-key", type=Path, default=Path.home() / ".ssh/youyidian_prod_ed25519")
parser.add_argument("--known-hosts", type=Path, default=Path.home() / ".ssh/known_hosts")
parser.add_argument("--receipts", type=Path, default=ROOT / ".git/releases")
args = parser.parse_args()
require(not args.retry_rolled_back or args.action == "activate", "Retry flag only applies to activation")
if args.action in {"stage", "activate", "rollback"}:
require(args.release and re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{12}", args.release), "--release is required")
args.receipts = args.receipts.resolve()
@ -243,10 +237,6 @@ def main():
stage(connections, args)
else:
kwargs = {"release": args.release} if args.release else {}
if args.action == "activate":
kwargs["operator-commit"] = git("rev-parse", "HEAD")
if args.retry_rolled_back:
kwargs["retry"] = "rolled-back"
result = connections.helper(args.action, **kwargs)
if args.release:
write_json(args.receipts / args.release / (args.action + ".json"), result)

View File

@ -53,27 +53,23 @@ 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()
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")
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")
@ -112,7 +108,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(cwd / "node_modules/next/dist/bin/next"),
require(env["pm_exec_path"] == str(APP / "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")
@ -128,7 +124,6 @@ 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()}
@ -174,30 +169,40 @@ 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())
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"],
result = {"schema": 1, "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"),
"runtimeFiles": runtime_files, "runtimeLinks": runtime_links}
require(runtime_files, "No runtime inventory")
"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")
json_write(source / "release.json", result)
# 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)
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)
return {"releaseId": workspace.name, "sourceCommit": result["sourceCommit"],
"buildId": result["buildId"], "artifactSha256": sha(workspace / "artifact.tar.gz"),
"artifactBytes": (workspace / "artifact.tar.gz").stat().st_size}
@ -242,95 +247,37 @@ def smoke(workspace):
process.wait()
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}")
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}")
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)
manifest = extract_runtime(archive, destination, current)
safe_extract(archive, destination)
manifest = json.loads((destination / "release.json").read_text())
require(manifest["releaseId"] == release_id, "Artifact release ID mismatch")
require(manifest["sourceCommit"][:12] == release_id[-12:], "Artifact source mismatch")
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"]:
verify_shared(manifest)
for name in ["node_modules", "public", ".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)
@ -356,7 +303,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": str(cwd / "node_modules/next/dist/bin/next"),
return {"apps": [{"name": NAME, "cwd": str(cwd), "script": old["pm_exec_path"],
"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),
@ -384,34 +331,21 @@ def healthy(cwd, build_id):
def restart(config):
apps = json.loads(config.read_text())["apps"]
require(len(apps) == 1 and apps[0]["name"] == NAME, "Refusing a multi-application PM2 definition")
current = [app for app in json.loads(run(["pm2", "jlist"])) if app["name"] == NAME]
require(len(current) <= 1, "Duplicate ai-chatbot definitions; operator required")
# PM2 restart updates env but retains the old executable/cwd. Recreate only
# this name so the new immutable release's process definition really applies.
if current:
run(["pm2", "delete", NAME], timeout=150)
run(["pm2", "start", str(config), "--only", NAME], timeout=150)
run(["pm2", "startOrRestart", str(config), "--only", NAME, "--update-env"], timeout=150)
def activate(release_id, retry=False, operator_commit=None):
def activate(release_id):
destination = release_path(release_id)
manifest = json.loads((destination / "release.json").read_text())
staged = json.loads((destination / "staged.json").read_text())
app, current, dist = app_state()
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
"Production changed after staging")
journal = destination / "activation.json"
prior = json.loads(journal.read_text()) if journal.exists() else None
require(not prior or (retry and prior.get("phase") == "rolled-back"),
"Already activated; inspect status before explicitly retrying a rolled-back attempt")
verify_runtime(manifest, destination)
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
verify_shared(manifest)
jobs = job_status()
require(not any(row["recent"] for rows in jobs.values() for row in rows),
"Recent active generation jobs: defer activation")
if prior:
json_write(destination / f"activation-history-{time.time_ns()}.json", prior)
before = ecosystem(app, current, dist)
previous_build = (current / dist / "BUILD_ID").read_text().strip()
json_write(destination / "previous.pm2.json", before)
@ -423,7 +357,7 @@ def activate(release_id, retry=False, operator_commit=None):
"previousBuildId": previous_build, "previousCwd": str(current),
"activatedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"historicalTaskStates": jobs, "artifactSha256": staged["artifactSha256"],
"phase": "activating", "operatorCommit": operator_commit}
"phase": "activating"}
json_write(destination / "activation.json", receipt)
try:
restart(destination / "candidate.pm2.json")
@ -466,8 +400,6 @@ def main():
parser.add_argument("--release")
parser.add_argument("--digest")
parser.add_argument("--workspace", type=Path)
parser.add_argument("--retry", choices=["rolled-back"])
parser.add_argument("--operator-commit")
args = parser.parse_args()
os.umask(0o077)
if args.action in {"pack", "smoke"}:
@ -484,8 +416,7 @@ def main():
if args.action == "stage":
result = stage(args.release, args.digest)
elif args.action == "activate":
result = activate(args.release, retry=args.retry == "rolled-back",
operator_commit=args.operator_commit)
result = activate(args.release)
else:
result = rollback(args.release)
print(json.dumps(result))

View File

@ -83,67 +83,6 @@ 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()
@ -166,7 +105,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_runtime"),
patch.object(host, "verify_shared"),
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=""),
@ -222,59 +161,6 @@ class ActivationTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "Rollback health check failed"):
host.activate(self.release_id)
def test_explicit_retry_preserves_the_failed_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "rolled-back", "previousBuildId": "old-build"})
with patch.object(host, "healthy", return_value=True):
result = host.activate(self.release_id, retry=True, operator_commit="tool-commit")
self.assertEqual(result["phase"], "active")
self.assertEqual(result["operatorCommit"], "tool-commit")
history = list(self.target.glob("activation-history-*.json"))
self.assertEqual(len(history), 1)
self.assertEqual(json.loads(history[0].read_text())["phase"], "rolled-back")
def test_retry_cannot_replace_an_active_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "active"})
with self.assertRaisesRegex(RuntimeError, "Already activated"):
host.activate(self.release_id, retry=True)
self.restart.assert_not_called()
def test_failed_process_start_also_restores_previous_definition(self):
self.restart.side_effect = [RuntimeError("candidate start failed"), None]
with patch.object(host, "healthy", return_value=True):
with self.assertRaisesRegex(RuntimeError, "candidate start failed"):
host.activate(self.release_id)
self.assertEqual(self.restart.call_args_list[-1].args[0].name, "previous.pm2.json")
class ProcessDefinitionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.config = Path(self.temporary.name) / "candidate.json"
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot", "cwd": "/new/release"}]}))
def tearDown(self):
self.temporary.cleanup()
def test_recreates_only_target_application(self):
with patch.object(host, "run", side_effect=[json.dumps([{"name": "ai-chatbot"}, {"name": "other-service"}]), "", ""]) as run:
host.restart(self.config)
self.assertEqual([call.args[0] for call in run.call_args_list], [
["pm2", "jlist"], ["pm2", "delete", "ai-chatbot"],
["pm2", "start", str(self.config), "--only", "ai-chatbot"]])
def test_can_restore_after_failed_candidate_left_no_process(self):
with patch.object(host, "run", side_effect=["[]", ""]) as run:
host.restart(self.config)
self.assertEqual(len(run.call_args_list), 2)
self.assertEqual(run.call_args_list[-1].args[0][1], "start")
def test_refuses_definitions_that_include_other_applications(self):
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot"}, {"name": "other"}]}))
with patch.object(host, "run") as run:
with self.assertRaisesRegex(RuntimeError, "multi-application"):
host.restart(self.config)
run.assert_not_called()
class HelperTests(unittest.TestCase):
def test_production_e2e_switch_blocks_release(self):