From 9d8638b8d2a4eac673df2017f16b3e042d6b3045 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Jul 2026 17:58:11 +0300 Subject: [PATCH] CHORE - DEPLOY: deterministic BIM and Tasker CAD artifacts --- .../build-bim-tasker-cad-ops-artifacts.mjs | 120 ++++++++++++++++++ .../test_bim_tasker_cad_ops_artifacts.py | 105 +++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 infra/deploy-runner/build-bim-tasker-cad-ops-artifacts.mjs create mode 100644 infra/deploy-runner/test_bim_tasker_cad_ops_artifacts.py diff --git a/infra/deploy-runner/build-bim-tasker-cad-ops-artifacts.mjs b/infra/deploy-runner/build-bim-tasker-cad-ops-artifacts.mjs new file mode 100644 index 0000000..a726d44 --- /dev/null +++ b/infra/deploy-runner/build-bim-tasker-cad-ops-artifacts.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const bimRoot = resolve(process.env.NODEDC_BIM_VIEWER_ROOT || resolve(platformRoot, "../NODEDC_BIM_VIEWER")); +const taskerRoot = resolve( + process.env.NODEDC_TASKMANAGER_ROOT || resolve(platformRoot, "../../data/dc_taskmanager/NODEDC_TASKMANAGER"), +); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [release = "20260729-001", ...extra] = process.argv.slice(2); + +if (extra.length || !/^\d{8}-\d{3}$/.test(release)) { + throw new Error("usage: build-bim-tasker-cad-ops-artifacts.mjs [YYYYMMDD-NNN]"); +} + +const descriptors = [ + { + artifactBasename: `nodedc-bim-viewer-cad-ops-${release}.tgz`, + component: "bim-viewer", + files: [ + "converter/worker.py", + "server/cad-formats.js", + "server/index.js", + ], + patchId: `bim-viewer-cad-ops-${release}`, + sourceRoot: bimRoot, + }, + { + artifactBasename: `nodedc-tasker-cad-ops-${release}.tgz`, + component: "tasker", + files: [ + "plane-src/apps/web/helpers/beam-viewer-config.ts", + "plane-src/apps/web/helpers/beam-viewer.ts", + ], + patchId: `tasker-cad-ops-${release}`, + sourceRoot: taskerRoot, + }, +]; + +await mkdir(artifactDir, { recursive: true }); +const artifacts = []; + +for (const descriptor of descriptors) { + const sourceCommit = gitOutput(descriptor.sourceRoot, ["rev-parse", "HEAD"]); + const sourceStatus = gitOutput(descriptor.sourceRoot, ["status", "--porcelain"]); + if (sourceStatus) throw new Error(`source_worktree_not_clean:${descriptor.component}`); + + const stage = await mkdtemp(join(tmpdir(), `nodedc-${descriptor.component}-cad-ops-`)); + const payload = join(stage, "payload"); + const artifact = join(artifactDir, descriptor.artifactBasename); + + try { + await mkdir(payload, { recursive: true }); + for (const relativePath of descriptor.files) { + const source = resolve(descriptor.sourceRoot, relativePath); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`source_file_rejected:${descriptor.component}:${relativePath}`); + } + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true, verbatimSymlinks: true }); + } + + await writeFile( + join(stage, "manifest.env"), + `id=${descriptor.patchId}\ncomponent=${descriptor.component}\ntype=app-overlay\n`, + "utf8", + ); + await writeFile(join(stage, "files.txt"), `${descriptor.files.join("\n")}\n`, "utf8"); + + const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`tar_failed:${descriptor.component}:${tar.stderr || tar.stdout}`); + + const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex"); + artifacts.push({ + artifact, + component: descriptor.component, + files: descriptor.files, + patchId: descriptor.patchId, + sha256, + sourceCommit, + }); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +console.log(JSON.stringify({ ok: true, release, artifacts }, null, 2)); + +function gitOutput(cwd, args) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git_failed:${cwd}:${args.join("_")}:${result.stderr || result.stdout}`); + return result.stdout.trim(); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} diff --git a/infra/deploy-runner/test_bim_tasker_cad_ops_artifacts.py b/infra/deploy-runner/test_bim_tasker_cad_ops_artifacts.py new file mode 100644 index 0000000..3b8097e --- /dev/null +++ b/infra/deploy-runner/test_bim_tasker_cad_ops_artifacts.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = SCRIPT_DIR / "build-bim-tasker-cad-ops-artifacts.mjs" + +EXPECTED = { + "bim-viewer": { + "files": [ + "converter/worker.py", + "server/cad-formats.js", + "server/index.js", + ], + "manifest": ( + "id=bim-viewer-cad-ops-20990101-001\n" + "component=bim-viewer\n" + "type=app-overlay\n" + ), + }, + "tasker": { + "files": [ + "plane-src/apps/web/helpers/beam-viewer-config.ts", + "plane-src/apps/web/helpers/beam-viewer.ts", + ], + "manifest": ( + "id=tasker-cad-ops-20990101-001\n" + "component=tasker\n" + "type=app-overlay\n" + ), + }, +} + + +class BimTaskerCadOpsArtifactsTest(unittest.TestCase): + def build(self, artifact_dir: Path) -> dict: + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + result = subprocess.run( + ["node", str(BUILDER), "20990101-001"], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_artifacts_are_exact_deterministic_and_runtime_only(self) -> None: + with ( + tempfile.TemporaryDirectory(prefix="nodedc-cad-ops-first-") as first_dir, + tempfile.TemporaryDirectory(prefix="nodedc-cad-ops-second-") as second_dir, + ): + first = self.build(Path(first_dir)) + second = self.build(Path(second_dir)) + + first_by_component = {item["component"]: item for item in first["artifacts"]} + second_by_component = {item["component"]: item for item in second["artifacts"]} + self.assertEqual(set(first_by_component), set(EXPECTED)) + self.assertEqual(set(second_by_component), set(EXPECTED)) + + for component, contract in EXPECTED.items(): + first_item = first_by_component[component] + second_item = second_by_component[component] + first_bytes = Path(first_item["artifact"]).read_bytes() + second_bytes = Path(second_item["artifact"]).read_bytes() + + self.assertEqual(first_bytes, second_bytes) + self.assertEqual(first_item["sha256"], hashlib.sha256(first_bytes).hexdigest()) + self.assertEqual(first_item["files"], contract["files"]) + self.assertRegex(first_item["sourceCommit"], r"^[0-9a-f]{40}$") + + with tarfile.open(first_item["artifact"], "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + self.assertEqual( + archive.extractfile("manifest.env").read().decode("utf-8"), + contract["manifest"], + ) + self.assertEqual( + archive.extractfile("files.txt").read().decode("utf-8").splitlines(), + contract["files"], + ) + + self.assertEqual(names[:3], ["manifest.env", "files.txt", "payload"]) + self.assertTrue(all(member.isfile() or member.isdir() for member in members)) + self.assertFalse(any(member.issym() or member.islnk() for member in members)) + self.assertFalse(any("test" in name.lower() for name in names)) + self.assertFalse( + any( + name.startswith("payload/") and ("/.env" in name or name.endswith(".env")) + for name in names + ) + ) + self.assertFalse(any(name.startswith("._") or "/._" in name for name in names)) + + +if __name__ == "__main__": + unittest.main()