diff --git a/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp index 9043118..beaa5cb 100644 --- a/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp +++ b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp @@ -1,6 +1,6 @@ # HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL -状态:本地实现候选,尚未部署 +状态:本地实现候选,尚未部署;当前现实层不得宣称可派发 语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定: @@ -11,3 +11,9 @@ 语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单。 没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。 + +`resource` 中的 `REQUEST-ID` 必须与清单文件名完全一致:`deployment/requests/.json`。批准 A 而执行 B 必须在派发端和常驻 Agent 端分别拒绝。 + +首次新单元使用 `guanghu.architecture-provision-request/v1`;已经在线的 systemd 服务必须使用 `guanghu.existing-service-update-request/v1`,先完整备份声明目标,再重启和逐项验收,失败恢复全部旧文件与旧单元。二者不得互相冒充。 + +当前京东节点若尚未安装 `/api/deployment/dispatch`、事件队列和常驻 worker,则先通过已有的主人邮件批准固定管理动作引导这套运行时。没有这个固定引导动作时应回执 `BOOTSTRAP_ACTION_NOT_DEPLOYED`,不得绕回人类 SSH 或云控制台,也不得发送一张服务器根本不会执行的部署工单。 diff --git a/deployment/requests/AI-DISCOVERY-ICE-P-ROUTE-20260727.json b/deployment/requests/AI-DISCOVERY-ICE-P-ROUTE-20260727.json new file mode 100644 index 0000000..ed5d22f --- /dev/null +++ b/deployment/requests/AI-DISCOVERY-ICE-P-ROUTE-20260727.json @@ -0,0 +1,108 @@ +{ + "schema": "guanghu.existing-service-update-request/v1", + "request_id": "AI-DISCOVERY-ICE-P-ROUTE-20260727", + "target_node": "JD-FD-PRIMARY", + "status": "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING", + "purpose": { + "what": "更新已在线的光湖 AI 发现服务,使公开只读入口加载完整第五域仓库、节点、主体与旧编号别名地图。", + "why": "代码频道已经把铸渊规范编号统一为 ICE-P-ZY001,但京东主节点仍运行旧服务,旧编号无法自动导航到规范编号,仓库事实尚未进入现实运行层。", + "success": "公开入口可读取主体注册表,并将 ICE-GL-ZY001 与 ICE-PZY-001 解析为 ICE-P-ZY001;REPO-012 与现有健康接口继续正常。", + "out_of_scope": [ + "不修改人类登录入口或 SSH", + "不修改广州前门与现有隧道", + "不部署企业应用栈", + "不授予任何旧编号权限" + ] + }, + "module": { + "unit": "guanghu-ai-discovery.service", + "run_user": "guanghu", + "install_root": "/opt/guanghu/ai-discovery" + }, + "service_update": { + "kind": "existing-systemd-service", + "require_existing_unit": true, + "required_existing_files": [ + "server.js" + ] + }, + "unit_source": "server-tools/ai-discovery-gateway/guanghu-ai-discovery.service", + "files": [ + { + "source": "server-tools/ai-discovery-gateway/server.js", + "destination": "server.js", + "mode": "0644" + }, + { + "source": "routing/repository-route-map.json", + "destination": "repository-route-map.json", + "mode": "0644" + }, + { + "source": "routing/server-node-map.json", + "destination": "server-node-map.json", + "mode": "0644" + }, + { + "source": "identity/fifth-domain-subject-registry.json", + "destination": "fifth-domain-subject-registry.json", + "mode": "0644" + }, + { + "source": "identity/subject-id-alias-map.json", + "destination": "subject-id-alias-map.json", + "mode": "0644" + } + ], + "runtime_check": { + "url": "http://127.0.0.1:3922/health", + "expected": { + "ok": true, + "service": "guanghu-ai-discovery", + "mode": "read-only" + } + }, + "acceptance_checks": [ + { + "url": "http://127.0.0.1:3922/v1/subjects", + "expected": { + "schema": "guanghu.fifth-domain-subject-registry/v1" + } + }, + { + "url": "http://127.0.0.1:3922/v1/resolve?id=ICE-GL-ZY001", + "expected": { + "status": "RESOLVED", + "canonical_id": "ICE-P-ZY001", + "redirected": true + } + }, + { + "url": "http://127.0.0.1:3922/v1/resolve?id=ICE-PZY-001", + "expected": { + "status": "RESOLVED", + "canonical_id": "ICE-P-ZY001", + "redirected": true + } + }, + { + "url": "http://127.0.0.1:3922/v1/resolve?id=REPO-012", + "expected": { + "code": "REPO-012", + "state": "CURRENT_FIFTH_DOMAIN_ENTRY" + } + } + ], + "post_deployment_observation": "常驻 Agent 完成本机强校验后,语言层必须再读取公开 /api/ai 对应接口;公开路径不一致时不得宣称整条链完成。", + "rollback": { + "automatic_on_failure": true, + "scope": "restore every declared destination and the previous systemd unit, then restart the previous service", + "receipt_required": true + }, + "security": { + "public_read_only": true, + "no_secrets": true, + "legacy_ids_are_navigation_aliases_only": true, + "human_login_state_unchanged": true + } +} diff --git a/deployment/requests/README.md b/deployment/requests/README.md index 4834bb7..cfc180f 100644 --- a/deployment/requests/README.md +++ b/deployment/requests/README.md @@ -1,6 +1,11 @@ -# 新架构首次部署请求 +# 部署请求 -本目录中的 `guanghu.architecture-provision-request/v1` 文件是新系统架构第一次进入服务器的结构化清单。 +本目录同时容纳两类严格区分的清单: + +- `guanghu.architecture-provision-request/v1`:服务器上尚不存在的新架构单元首次进入现实层; +- `guanghu.existing-service-update-request/v1`:更新已经存在的 systemd 服务,必须先备份全部声明目标,重启后做回环健康检查,失败则恢复全部文件和旧单元。 + +不得用“首次部署”清单替代已有服务升级,也不得把升级失败降级为继续运行未验证的新文件。 固定流程: diff --git a/server-tools/ai-discovery-gateway/guanghu-ai-discovery.service b/server-tools/ai-discovery-gateway/guanghu-ai-discovery.service index 61010d6..69f5364 100644 --- a/server-tools/ai-discovery-gateway/guanghu-ai-discovery.service +++ b/server-tools/ai-discovery-gateway/guanghu-ai-discovery.service @@ -11,6 +11,9 @@ WorkingDirectory=/opt/guanghu/ai-discovery Environment=GUANGHU_AI_HOST=127.0.0.1 Environment=GUANGHU_AI_PORT=3922 Environment=GUANGHU_REPOSITORY_MAP=/opt/guanghu/ai-discovery/repository-route-map.json +Environment=GUANGHU_NODE_MAP=/opt/guanghu/ai-discovery/server-node-map.json +Environment=GUANGHU_SUBJECT_REGISTRY=/opt/guanghu/ai-discovery/fifth-domain-subject-registry.json +Environment=GUANGHU_SUBJECT_ALIAS_MAP=/opt/guanghu/ai-discovery/subject-id-alias-map.json ExecStart=/usr/bin/node /opt/guanghu/ai-discovery/server.js Restart=always RestartSec=5 diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index 3545145..d076545 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -102,23 +102,27 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \ - `restore-code-channel-owner-login` 只把京东本机旧第五域数据库中的 `bingshuo` 密码摘要恢复到新代码频道,同时校正启用、管理员和禁止登录状态。它不读取明文密码,不修改 SSH,并在变更前使用 SQLite backup API 生成一致性备份。 - 登录问题先执行 `inspect-code-channel-owner-auth`。只有确认新旧频道凭证不一致时,才申请后一项恢复动作;不得绕到新加坡灾备节点,不得向冰朔索要密码。 -## 新架构首次部署 +## 首次部署与已有服务更新 旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单: ```bash node request-workorder.js \ --url https://guanghulab.com/authz \ - --persona ICE-GL-ZY001 \ + --persona ICE-P-ZY001 \ --name 铸渊 \ --target JD-FD-PRIMARY \ --scope server-ops \ --action provision-approved-architecture \ --resource 'REQUEST-ID@40位提交SHA' \ - --description '首次安装已审核架构包' + --description '安装或更新已审核的不可变部署包' ``` -邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交。执行器只读取该提交中 `deployment/requests/.json`,只复制清单列出的普通文件,只安装清单指定的非 root、加固 systemd 单元,并只接受回环健康检查。人格体可以使用清单声明的独立低权限账户、共享模型密钥文件和状态目录;密钥路径必须位于 `/etc/guanghu/persona-secrets/`,可写路径必须位于 `/var/lib/guanghu/personas/<运行账户>/`。覆盖旧单元前强制备份,启动或验收失败时自动恢复。说明文字不能改变部署内容,也不开放任意 shell。 +邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交,清单路径也必须严格等于 `deployment/requests/.json`。 + +首次部署执行器只复制清单列出的普通文件,只安装清单指定的非 root、加固 systemd 单元,并只接受回环健康检查。人格体可以使用清单声明的独立低权限账户、共享模型密钥文件和状态目录;密钥路径必须位于 `/etc/guanghu/persona-secrets/`,可写路径必须位于 `/var/lib/guanghu/personas/<运行账户>/`。 + +已有服务更新必须使用 `guanghu.existing-service-update-request/v1`,不得伪装成首次部署。执行器先确认旧单元和必需旧文件存在,再备份单元及每个声明目标,只写入清单列出的 `/opt/guanghu/<服务>/` 文件,重启原单元并逐项执行回环验收;任一检查失败即恢复全部旧文件和旧单元。说明文字不能改变部署内容,也不开放任意 shell。 这套入口本身需要在京东主控上一次性安装: @@ -126,4 +130,4 @@ node request-workorder.js \ sudo bash server-tools/lake-lamp-authz/install-architecture-provisioner.sh ``` -这是最后一次需要云厂商控制台或现有系统管理通道的引导。安装完成后,未来新架构均走上面的结构化工单,不必预先把每个未来模块写进旧动作桥。 +引导本身也是一次受控部署:应从已经存在的主人邮件批准系统管理通道执行,不得为此恢复人类 SSH 登录。若当前服务器尚未登记能够安装此运行时的固定动作,状态必须记为 `BOOTSTRAP_ACTION_NOT_DEPLOYED`,不能把仓库提交、工单批准或打开云后台冒充成运行时已部署。引导完成并读回三个服务均为 active 后,未来安装与更新才走通感桥的结构化工单。 diff --git a/server-tools/lake-lamp-authz/architecture-provision-broker.js b/server-tools/lake-lamp-authz/architecture-provision-broker.js index c08857c..afd7052 100644 --- a/server-tools/lake-lamp-authz/architecture-provision-broker.js +++ b/server-tools/lake-lamp-authz/architecture-provision-broker.js @@ -24,19 +24,59 @@ function safeRelative(value) { } function validateManifest(manifest, resource) { - if (!manifest || manifest.schema !== "guanghu.architecture-provision-request/v1") throw new Error("invalid_manifest_schema"); + if (!manifest || !["guanghu.architecture-provision-request/v1", "guanghu.existing-service-update-request/v1"].includes(manifest.schema)) throw new Error("invalid_manifest_schema"); if (manifest.request_id !== resource.requestId || manifest.target_node !== "JD-FD-PRIMARY") throw new Error("manifest_identity_mismatch"); - if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending"); - if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit"); const unit = String(manifest.module && manifest.module.unit || ""); if (!/^[A-Za-z0-9_.@-]+\.service$/.test(unit)) throw new Error("invalid_unit_name"); + if (manifest.schema === "guanghu.existing-service-update-request/v1") return validateServiceUpdateManifest(manifest, unit); + if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending"); + if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit"); if (!Array.isArray(manifest.source_paths) || manifest.source_paths.length < 1 || manifest.source_paths.length > 64 || manifest.source_paths.some(item => !safeRelative(item))) throw new Error("invalid_source_paths"); const unitMatches = manifest.source_paths.filter(item => path.basename(item) === unit); if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared"); const check = manifest.runtime_check || {}; if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check"); if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation"); - return { unit, unitSource: unitMatches[0], runtimeCheck: check }; + return { kind: "initial-provision", unit, unitSource: unitMatches[0], sourcePaths: manifest.source_paths, runtimeCheck: check }; +} + +function validateServiceUpdateManifest(manifest, unit) { + if (manifest.status !== "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING") throw new Error("manifest_not_pending"); + if (!manifest.service_update || manifest.service_update.kind !== "existing-systemd-service" || manifest.service_update.require_existing_unit !== true) throw new Error("not_existing_service_update"); + const installRoot = String(manifest.module && manifest.module.install_root || ""); + if (!/^\/opt\/guanghu\/[a-z0-9][a-z0-9-]{1,62}$/.test(installRoot)) throw new Error("invalid_service_install_root"); + const unitSource = String(manifest.unit_source || ""); + if (!safeRelative(unitSource) || path.basename(unitSource) !== unit) throw new Error("invalid_unit_source"); + if (!Array.isArray(manifest.files) || manifest.files.length < 1 || manifest.files.length > 64) throw new Error("invalid_update_files"); + const seenDestinations = new Set(); + for (const item of manifest.files) { + if (!item || !safeRelative(item.source) || !safeRelative(item.destination)) throw new Error("invalid_update_file_path"); + if (!/^(?:0?644|0?755)$/.test(String(item.mode || ""))) throw new Error("invalid_update_file_mode"); + if (seenDestinations.has(item.destination)) throw new Error("duplicate_update_destination"); + seenDestinations.add(item.destination); + } + const requiredExisting = manifest.service_update.required_existing_files || []; + if (!Array.isArray(requiredExisting) || requiredExisting.some(item => !safeRelative(item) || !seenDestinations.has(item))) throw new Error("invalid_required_existing_files"); + const check = manifest.runtime_check || {}; + if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check"); + if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation"); + const acceptanceChecks = manifest.acceptance_checks || []; + if (!Array.isArray(acceptanceChecks) || acceptanceChecks.length > 16) throw new Error("invalid_acceptance_checks"); + for (const item of acceptanceChecks) { + if (!item || !/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(item.url || ""))) throw new Error("invalid_acceptance_check_url"); + if (!item.expected || typeof item.expected !== "object" || Array.isArray(item.expected)) throw new Error("invalid_acceptance_expectation"); + } + return { + kind: "existing-service-update", + unit, + unitSource, + installRoot, + files: manifest.files, + requiredExisting, + sourcePaths: [unitSource, ...manifest.files.map(item => item.source)], + runtimeCheck: check, + acceptanceChecks, + }; } function declaredPaths(value) { @@ -69,6 +109,17 @@ function validateUnit(text, expectedUser = "guanghu", policy = {}) { return value; } +function validateUpdateUnit(text, expectedUser, installRoot) { + const value = String(text || ""); + if (!value.includes("[Service]") || !/^NoNewPrivileges=(true|yes)$/m.test(value) || !/^ProtectSystem=strict$/m.test(value) || !/^ProtectHome=(true|yes)$/m.test(value) || !/^PrivateTmp=(true|yes)$/m.test(value)) throw new Error("unit_hardening_required"); + if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(expectedUser) || expectedUser === "root" || !new RegExp(`^User=${expectedUser}$`, "m").test(value) || !new RegExp(`^Group=${expectedUser}$`, "m").test(value)) throw new Error("dedicated_service_user_required"); + if (!value.includes(`WorkingDirectory=${installRoot}`) || !value.includes(`ReadOnlyPaths=${installRoot}`)) throw new Error("service_install_root_not_confined"); + const execStart = value.match(/^ExecStart=(.+)$/m); + if (!execStart || !execStart[1].includes(`${installRoot}/`) || /[;&|`$<>]/.test(execStart[1])) throw new Error("service_exec_start_not_confined"); + if (/^(SupplementaryGroups|AmbientCapabilities|BindPaths|BindReadOnlyPaths|RootDirectory|RootImage|DeviceAllow|EnvironmentFile|ReadWritePaths)=/m.test(value)) throw new Error("privileged_unit_directive_forbidden"); + return value; +} + async function provision(request, options = {}) { if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" }; const resource = parseResource(request.resource); @@ -84,10 +135,17 @@ async function provision(request, options = {}) { await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL); const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - const checked = validateManifest(manifest, resource); + let checked = validateManifest(manifest, resource); + if (checked.kind === "existing-service-update") { + if (options.installRootOverride) checked = { ...checked, installRoot: options.installRootOverride }; + return await updateExistingService({ + checked, manifest, resource, repoDir, releasesDir, unitDir, receiptsDir, run, + getJson: options.getJson, healthAttempts: options.healthAttempts, healthDelayMs: options.healthDelayMs, + }); + } const releaseRoot = path.join(releasesDir, resource.commit); fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 }); - for (const relative of manifest.source_paths) copyDeclaredFile(repoDir, releaseRoot, relative); + for (const relative of checked.sourcePaths) copyDeclaredFile(repoDir, releaseRoot, relative); const unitSource = path.join(releaseRoot, checked.unitSource); const unitText = validateUnit(fs.readFileSync(unitSource, "utf8"), String(manifest.module.run_user || ""), manifest.module).replaceAll("__RELEASE_ROOT__", releaseRoot); fs.mkdirSync(unitDir, { recursive: true, mode: 0o755 }); @@ -120,6 +178,98 @@ async function provision(request, options = {}) { } } +async function updateExistingService(context) { + const { checked, manifest, resource, repoDir, releasesDir, unitDir, receiptsDir, run } = context; + const releaseRoot = path.join(releasesDir, resource.commit); + const installedUnit = path.join(unitDir, checked.unit); + const backupDir = path.join(receiptsDir, "backups", resource.requestId, resource.commit); + const backups = []; + let updateStarted = false; + try { + if (!fs.existsSync(installedUnit) || !fs.lstatSync(installedUnit).isFile() || fs.lstatSync(installedUnit).isSymbolicLink()) throw new Error("existing_service_unit_required"); + for (const relative of checked.requiredExisting) { + const existing = path.join(checked.installRoot, relative); + if (!fs.existsSync(existing) || !fs.lstatSync(existing).isFile() || fs.lstatSync(existing).isSymbolicLink()) throw new Error(`required_existing_file_missing:${relative}`); + } + fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 }); + for (const relative of checked.sourcePaths) copyDeclaredFile(repoDir, releaseRoot, relative); + const unitText = validateUpdateUnit(fs.readFileSync(path.join(releaseRoot, checked.unitSource), "utf8"), String(manifest.module.run_user || ""), checked.installRoot); + fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + backupFile(installedUnit, path.join(backupDir, "systemd", checked.unit), backups); + for (const item of checked.files) { + const destination = path.join(checked.installRoot, item.destination); + backupFile(destination, path.join(backupDir, "files", item.destination), backups); + } + updateStarted = true; + fs.mkdirSync(checked.installRoot, { recursive: true, mode: 0o755 }); + for (const item of checked.files) { + const destination = path.join(checked.installRoot, item.destination); + if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) throw new Error("update_destination_symlink_forbidden"); + fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 }); + writeAtomic(destination, fs.readFileSync(path.join(releaseRoot, item.source)), Number.parseInt(String(item.mode), 8)); + } + writeAtomic(installedUnit, unitText, 0o644); + await run("/usr/bin/systemctl", ["daemon-reload"]); + await run("/usr/bin/systemctl", ["restart", checked.unit]); + const runtime = await getJsonWithRetry(checked.runtimeCheck.url, context.getJson, context.healthAttempts, context.healthDelayMs); + for (const [key, expected] of Object.entries(checked.runtimeCheck.expected)) if (runtime[key] !== expected) throw new Error(`runtime_check_failed:${key}`); + for (const check of checked.acceptanceChecks) { + const observed = await getJsonWithRetry(check.url, context.getJson, context.healthAttempts, context.healthDelayMs); + for (const [key, expected] of Object.entries(check.expected)) if (observed[key] !== expected) throw new Error(`acceptance_check_failed:${key}`); + } + const receipt = { + schema: "guanghu.existing-service-update-receipt/v1", + request_id: resource.requestId, + source_commit: resource.commit, + target_node: "JD-FD-PRIMARY", + unit: checked.unit, + install_root: checked.installRoot, + runtime_check: checked.runtimeCheck.url, + acceptance_checks: checked.acceptanceChecks.map(item => item.url), + backup: path.join("backups", resource.requestId, resource.commit), + rollback: "restore-all-declared-files-and-previous-unit", + result: "DEPLOYED_AND_VERIFIED", + recorded_at: new Date().toISOString(), + }; + fs.mkdirSync(receiptsDir, { recursive: true, mode: 0o700 }); + writeAtomic(path.join(receiptsDir, `${resource.requestId}.json`), `${JSON.stringify(receipt, null, 2)}\n`, 0o600); + return { ok: true, request_id: resource.requestId, source_commit: resource.commit, unit: checked.unit, runtime: "verified" }; + } catch (error) { + if (updateStarted) { + try { + restoreBackups(backups); + await run("/usr/bin/systemctl", ["daemon-reload"]); + await run("/usr/bin/systemctl", ["restart", checked.unit]); + } catch { /* The original error remains authoritative; backups stay available for recovery. */ } + } + return { ok: false, error: String(error && error.message || "service_update_failed").slice(0, 240) }; + } +} + +function backupFile(source, backup, records) { + if (!fs.existsSync(source)) { + records.push({ source, backup, existed: false }); + return; + } + const stat = fs.lstatSync(source); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("backup_source_not_regular_file"); + fs.mkdirSync(path.dirname(backup), { recursive: true, mode: 0o700 }); + fs.copyFileSync(source, backup, fs.constants.COPYFILE_EXCL); + fs.chmodSync(backup, stat.mode & 0o777); + records.push({ source, backup, existed: true, mode: stat.mode & 0o777 }); +} + +function restoreBackups(records) { + for (const record of records.slice().reverse()) { + if (!record.existed) { + fs.rmSync(record.source, { force: true }); + continue; + } + fs.mkdirSync(path.dirname(record.source), { recursive: true, mode: 0o755 }); + writeAtomic(record.source, fs.readFileSync(record.backup), record.mode); + } +} + async function prepareRepo(repoDir, commit, run, repoUrl) { fs.mkdirSync(path.dirname(repoDir), { recursive: true, mode: 0o700 }); if (!fs.existsSync(path.join(repoDir, ".git"))) await run("/usr/bin/git", ["clone", "--filter=blob:none", "--no-checkout", repoUrl, repoDir]); @@ -197,4 +347,4 @@ if (require.main === module) { server.listen(SOCKET_PATH, () => { fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0)); fs.chmodSync(SOCKET_PATH, 0o660); }); } -module.exports = { parseResource, safeRelative, validateManifest, validateUnit, provision }; +module.exports = { parseResource, safeRelative, validateManifest, validateUnit, validateUpdateUnit, provision }; diff --git a/server-tools/lake-lamp-authz/architecture-provision-broker.test.js b/server-tools/lake-lamp-authz/architecture-provision-broker.test.js index 824a8fa..398dead 100644 --- a/server-tools/lake-lamp-authz/architecture-provision-broker.test.js +++ b/server-tools/lake-lamp-authz/architecture-provision-broker.test.js @@ -5,7 +5,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { parseResource, safeRelative, validateManifest, validateUnit, provision } = require("./architecture-provision-broker"); +const { parseResource, safeRelative, validateManifest, validateUnit, validateUpdateUnit, provision } = require("./architecture-provision-broker"); const commit = "d".repeat(40); const requestId = "GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720"; @@ -44,6 +44,25 @@ test("unit permits a declared persona user, shared secret and state directory", assert.throws(() => validateUnit(unit.replaceAll("kezhou", "root"), "root", policy), /dedicated_service_user_required/); }); +test("AI discovery update package declares all four route maps and passes the existing-service policy", () => { + const root = path.resolve(__dirname, "../.."); + const request = JSON.parse(fs.readFileSync(path.join(root, "deployment", "requests", "AI-DISCOVERY-ICE-P-ROUTE-20260727.json"))); + const checked = validateManifest(request, { requestId: request.request_id, commit }); + assert.equal(checked.kind, "existing-service-update"); + assert.deepEqual(checked.files.map(item => item.destination), [ + "server.js", + "repository-route-map.json", + "server-node-map.json", + "fifth-domain-subject-registry.json", + "subject-id-alias-map.json", + ]); + const unit = fs.readFileSync(path.join(root, request.unit_source), "utf8"); + assert.equal(validateUpdateUnit(unit, request.module.run_user, request.module.install_root), unit); + for (const variable of ["GUANGHU_REPOSITORY_MAP", "GUANGHU_NODE_MAP", "GUANGHU_SUBJECT_REGISTRY", "GUANGHU_SUBJECT_ALIAS_MAP"]) { + assert.match(unit, new RegExp(`^Environment=${variable}=`, "m")); + } +}); + test("provision copies only declared files and verifies loopback health", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-provision-")); const repoDir = path.join(root, "repo"); @@ -75,3 +94,96 @@ test("provision copies only declared files and verifies loopback health", async assert.equal(healthChecks, 2); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + +test("existing service update backs up declared files, restarts, and verifies identity routes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "existing-service-update-")); + const repoDir = path.join(root, "repo"), releasesDir = path.join(root, "releases"); + const unitDir = path.join(root, "units"), receiptsDir = path.join(root, "receipts"); + const installRoot = path.join(root, "opt", "ai-discovery"); + const updateRequestId = "AI-DISCOVERY-ICE-P-ROUTE-20260727"; + const updateManifest = { + schema: "guanghu.existing-service-update-request/v1", + request_id: updateRequestId, + target_node: "JD-FD-PRIMARY", + status: "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING", + module: { unit: "guanghu-ai-discovery.service", run_user: "guanghu", install_root: "/opt/guanghu/ai-discovery" }, + service_update: { kind: "existing-systemd-service", require_existing_unit: true, required_existing_files: ["server.js"] }, + unit_source: "server-tools/ai-discovery/guanghu-ai-discovery.service", + files: [{ source: "server-tools/ai-discovery/server.js", destination: "server.js", mode: "0644" }], + runtime_check: { url: "http://127.0.0.1:3922/health", expected: { ok: true, mode: "read-only" } }, + acceptance_checks: [{ url: "http://127.0.0.1:3922/v1/resolve?id=ICE-GL-ZY001", expected: { canonical_id: "ICE-P-ZY001", redirected: true } }], + }; + fs.mkdirSync(path.join(repoDir, "deployment", "requests"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "server-tools", "ai-discovery"), { recursive: true }); + fs.mkdirSync(unitDir, { recursive: true }); + fs.mkdirSync(installRoot, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "deployment", "requests", `${updateRequestId}.json`), JSON.stringify(updateManifest)); + fs.writeFileSync(path.join(repoDir, "server-tools", "ai-discovery", "server.js"), "new server\n"); + const newUnit = `[Service]\nUser=guanghu\nGroup=guanghu\nWorkingDirectory=${installRoot}\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadOnlyPaths=${installRoot}\nExecStart=/usr/bin/node ${installRoot}/server.js\n`; + fs.writeFileSync(path.join(repoDir, "server-tools", "ai-discovery", "guanghu-ai-discovery.service"), newUnit); + fs.writeFileSync(path.join(installRoot, "server.js"), "old server\n"); + fs.writeFileSync(path.join(unitDir, "guanghu-ai-discovery.service"), "old unit\n"); + const commands = []; + try { + const result = await provision({ target: "JD-FD-PRIMARY", action: "provision-approved-architecture", resource: `${updateRequestId}@${commit}` }, { + repoDir, releasesDir, unitDir, receiptsDir, installRootOverride: installRoot, + run: async (file, args) => { commands.push([file, args]); return { stdout: args.includes("rev-parse") ? `${commit}\n` : "" }; }, + getJson: async url => url.endsWith("/health") ? { ok: true, mode: "read-only" } : { canonical_id: "ICE-P-ZY001", redirected: true }, + healthDelayMs: 0, + }); + assert.equal(result.ok, true); + assert.equal(fs.readFileSync(path.join(installRoot, "server.js"), "utf8"), "new server\n"); + assert.equal(commands.some(([, args]) => args[0] === "restart" && args[1] === "guanghu-ai-discovery.service"), true); + const receipt = JSON.parse(fs.readFileSync(path.join(receiptsDir, `${updateRequestId}.json`))); + assert.equal(receipt.result, "DEPLOYED_AND_VERIFIED"); + assert.equal(receipt.acceptance_checks.length, 1); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("existing service update restores every changed file when acceptance fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "existing-service-rollback-")); + const repoDir = path.join(root, "repo"), releasesDir = path.join(root, "releases"); + const unitDir = path.join(root, "units"), receiptsDir = path.join(root, "receipts"); + const installRoot = path.join(root, "opt", "ai-discovery"); + const updateRequestId = "AI-DISCOVERY-ROLLBACK-TEST"; + const updateManifest = { + schema: "guanghu.existing-service-update-request/v1", + request_id: updateRequestId, + target_node: "JD-FD-PRIMARY", + status: "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING", + module: { unit: "guanghu-ai-discovery.service", run_user: "guanghu", install_root: "/opt/guanghu/ai-discovery" }, + service_update: { kind: "existing-systemd-service", require_existing_unit: true, required_existing_files: ["server.js"] }, + unit_source: "server-tools/ai-discovery/guanghu-ai-discovery.service", + files: [ + { source: "server-tools/ai-discovery/server.js", destination: "server.js", mode: "0644" }, + { source: "identity/alias.json", destination: "alias.json", mode: "0644" } + ], + runtime_check: { url: "http://127.0.0.1:3922/health", expected: { ok: true } }, + acceptance_checks: [], + }; + fs.mkdirSync(path.join(repoDir, "deployment", "requests"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "server-tools", "ai-discovery"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "identity"), { recursive: true }); + fs.mkdirSync(unitDir, { recursive: true }); + fs.mkdirSync(installRoot, { recursive: true }); + fs.writeFileSync(path.join(repoDir, "deployment", "requests", `${updateRequestId}.json`), JSON.stringify(updateManifest)); + fs.writeFileSync(path.join(repoDir, "server-tools", "ai-discovery", "server.js"), "new server\n"); + fs.writeFileSync(path.join(repoDir, "identity", "alias.json"), "{}\n"); + fs.writeFileSync(path.join(repoDir, "server-tools", "ai-discovery", "guanghu-ai-discovery.service"), `[Service]\nUser=guanghu\nGroup=guanghu\nWorkingDirectory=${installRoot}\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadOnlyPaths=${installRoot}\nExecStart=/usr/bin/node ${installRoot}/server.js\n`); + fs.writeFileSync(path.join(installRoot, "server.js"), "old server\n"); + fs.writeFileSync(path.join(unitDir, "guanghu-ai-discovery.service"), "old unit\n"); + try { + const result = await provision({ target: "JD-FD-PRIMARY", action: "provision-approved-architecture", resource: `${updateRequestId}@${commit}` }, { + repoDir, releasesDir, unitDir, receiptsDir, installRootOverride: installRoot, + run: async (file, args) => ({ stdout: args.includes("rev-parse") ? `${commit}\n` : "" }), + getJson: async () => ({ ok: false }), + healthAttempts: 1, + healthDelayMs: 0, + }); + assert.equal(result.ok, false); + assert.match(result.error, /runtime_check_failed:ok/); + assert.equal(fs.readFileSync(path.join(installRoot, "server.js"), "utf8"), "old server\n"); + assert.equal(fs.existsSync(path.join(installRoot, "alias.json")), false); + assert.equal(fs.readFileSync(path.join(unitDir, "guanghu-ai-discovery.service"), "utf8"), "old unit\n"); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/server-tools/lake-lamp-authz/deployment-event-worker.js b/server-tools/lake-lamp-authz/deployment-event-worker.js index 6b63610..8e34680 100644 --- a/server-tools/lake-lamp-authz/deployment-event-worker.js +++ b/server-tools/lake-lamp-authz/deployment-event-worker.js @@ -46,8 +46,10 @@ async function processOne(options = {}) { function validateEvent(event, registry) { if (!event || event.schema !== "guanghu.deployment-event/v1" || event.state !== "queued_for_resident_agent") return "deployment_event_schema_invalid"; if (!registry[event.repo] || !/^bingshuo\/[a-z0-9._-]+$/.test(event.repo) || event.branch !== "main" || !/^[0-9a-f]{40}$/.test(event.commit_sha || "")) return "deployment_event_binding_invalid"; - if (!/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(event.resource || "") || !event.resource.endsWith(`@${event.commit_sha}`)) return "deployment_event_resource_invalid"; + const resource = String(event.resource || "").match(/^([A-Z0-9][A-Z0-9._-]{5,119})@([0-9a-f]{40})$/); + if (!resource || resource[2] !== event.commit_sha) return "deployment_event_resource_invalid"; if (!/^deployment\/requests\/[A-Za-z0-9._/-]{1,180}\.json$/.test(event.manifest || "")) return "deployment_event_manifest_invalid"; + if (event.manifest !== `deployment/requests/${resource[1]}.json`) return "deployment_event_manifest_resource_mismatch"; return validateDeploymentSource(event, registry); } function writeAtomic(file, content) { const temp = `${file}.${process.pid}.tmp`; fs.writeFileSync(temp, content, { mode: 0o600 }); fs.renameSync(temp, file); } diff --git a/server-tools/lake-lamp-authz/deployment-event-worker.test.js b/server-tools/lake-lamp-authz/deployment-event-worker.test.js index e524412..79834ee 100644 --- a/server-tools/lake-lamp-authz/deployment-event-worker.test.js +++ b/server-tools/lake-lamp-authz/deployment-event-worker.test.js @@ -7,7 +7,7 @@ const path = require("node:path"); const { processOne } = require("./deployment-event-worker"); test("resident deployment agent consumes only an explicit immutable event and writes a receipt", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-worker-")), queue = path.join(root, "queue"), receipts = path.join(root, "receipts"), sha = "a".repeat(40); - fs.mkdirSync(queue); fs.writeFileSync(path.join(queue, "event.json"), JSON.stringify({ schema: "guanghu.deployment-event/v1", event_id: "event-1", state: "queued_for_resident_agent", repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha, workorder_id: "order-1", resource: `GLS-0239-DEPLOY@${sha}`, manifest: "deployment/requests/GLS-0239.json" })); + fs.mkdirSync(queue); fs.writeFileSync(path.join(queue, "event.json"), JSON.stringify({ schema: "guanghu.deployment-event/v1", event_id: "event-1", state: "queued_for_resident_agent", repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha, workorder_id: "order-1", resource: `GLS-0239-DEPLOY@${sha}`, manifest: "deployment/requests/GLS-0239-DEPLOY.json" })); try { const result = await processOne({ queueDir: queue, receiptsDir: receipts, registry: { "bingshuo/guanghu-ice-heart": { repo_url: "https://example.invalid/code.git" } }, provisionFn: async request => { assert.equal(request.resource, `GLS-0239-DEPLOY@${sha}`); return { ok: true, unit: "example.service" }; } }); assert.equal(result.state, "DEPLOYED_AND_VERIFIED"); @@ -27,7 +27,7 @@ test("resident agent rechecks source ownership before deployment", async () => { commit_sha: sha, workorder_id: "order-2", resource: `HLP-PERSONAL-DEPLOY@${sha}`, - manifest: "deployment/requests/HLP-PERSONAL.json", + manifest: "deployment/requests/HLP-PERSONAL-DEPLOY.json", authorizer_id: "ICE-GL∞", persona_id: "AGE-TEAM-001", execution_runtime_id: "SYS-GLW-ZY-EXEC-0001", diff --git a/server-tools/lake-lamp-authz/deployment-event.js b/server-tools/lake-lamp-authz/deployment-event.js index e29625f..a1cdf04 100644 --- a/server-tools/lake-lamp-authz/deployment-event.js +++ b/server-tools/lake-lamp-authz/deployment-event.js @@ -35,8 +35,10 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) { function validateIntent(intent, push, context = {}) { if (intent.schema !== "guanghu.deployment-intent/v1") return "deployment_intent_schema_invalid"; if (String(intent.repo || "").toLowerCase() !== push.repo || intent.branch !== push.branch || String(intent.commit_sha || "").toLowerCase() !== push.commit_sha) return "deployment_intent_binding_mismatch"; - if (!/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(String(intent.resource || "")) || !String(intent.resource).endsWith(`@${push.commit_sha}`)) return "deployment_intent_resource_invalid"; + const resource = String(intent.resource || "").match(/^([A-Z0-9][A-Z0-9._-]{5,119})@([0-9a-f]{40})$/); + if (!resource || resource[2] !== push.commit_sha) return "deployment_intent_resource_invalid"; if (!/^deployment\/requests\/[A-Za-z0-9._/-]{1,180}\.json$/.test(String(intent.manifest || ""))) return "deployment_intent_manifest_invalid"; + if (intent.manifest !== `deployment/requests/${resource[1]}.json`) return "deployment_intent_manifest_resource_mismatch"; if (context.registry) { return validateDeploymentSource({ repo: push.repo, diff --git a/server-tools/lake-lamp-authz/deployment-event.test.js b/server-tools/lake-lamp-authz/deployment-event.test.js index 8dc6b81..3b6d97c 100644 --- a/server-tools/lake-lamp-authz/deployment-event.test.js +++ b/server-tools/lake-lamp-authz/deployment-event.test.js @@ -32,10 +32,10 @@ test("only an immutable deployment intent creates a resident-agent event", () => const queue = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-events-")); const sha = "a".repeat(40), push = { repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha }; try { - const queued = enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${sha}`, manifest: "deployment/requests/GLS-0239.json", workorder_id: "order-1" }, push, queue); + const queued = enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${sha}`, manifest: "deployment/requests/GLS-0239-DEPLOY.json", workorder_id: "order-1" }, push, queue); assert.equal(queued.state, "queued_for_resident_agent"); assert.equal(fs.readdirSync(queue).length, 1); - assert.equal(enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${"b".repeat(40)}`, manifest: "deployment/requests/GLS-0239.json" }, push, queue).diagnostic_code, "deployment_intent_resource_invalid"); + assert.equal(enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${"b".repeat(40)}`, manifest: "deployment/requests/GLS-0239-DEPLOY.json" }, push, queue).diagnostic_code, "deployment_intent_resource_invalid"); } finally { fs.rmSync(queue, { recursive: true, force: true }); } }); test("team personas cannot dispatch Ice Shuo personal source", () => { @@ -48,7 +48,7 @@ test("team personas cannot dispatch Ice Shuo personal source", () => { branch: push.branch, commit_sha: sha, resource: `HLP-PERSONAL-DEPLOY@${sha}`, - manifest: "deployment/requests/HLP-PERSONAL.json", + manifest: "deployment/requests/HLP-PERSONAL-DEPLOY.json", deployment_source: personalSource, }; try { diff --git a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh index bd08d66..4493057 100755 --- a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh +++ b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh @@ -10,16 +10,25 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) install_root=/opt/guanghu/lake-lamp-authz install -d -m 0755 "$install_root" -for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js; do +for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js; do install -m 0644 "$script_dir/$file" "$install_root/$file" done install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service +install -m 0644 "$script_dir/lake-lamp-deployment-event-worker.service" /etc/systemd/system/lake-lamp-deployment-event-worker.service install -d -m 0700 /var/lib/guanghu/architecture-provision +install -d -m 0750 /var/lib/guanghu/deployment-events +install -d -m 0700 /var/lib/guanghu/deployment-events/receipts install -d -m 0755 /opt/guanghu/architecture-releases +install -d -m 0755 /etc/guanghu/lake-lamp +if [[ ! -e /etc/guanghu/lake-lamp/deployment-repositories.json ]]; then + install -m 0644 "$script_dir/deployment-repositories.example.json" /etc/guanghu/lake-lamp/deployment-repositories.json +fi systemctl daemon-reload systemctl enable --now lake-lamp-architecture-provision.service systemctl restart lake-lamp-authz.service +systemctl enable --now lake-lamp-deployment-event-worker.service systemctl is-active --quiet lake-lamp-architecture-provision.service systemctl is-active --quiet lake-lamp-authz.service +systemctl is-active --quiet lake-lamp-deployment-event-worker.service echo ARCHITECTURE_PROVISIONER_INSTALLED diff --git a/server-tools/lake-lamp-authz/lake-lamp-deployment-event-worker.service b/server-tools/lake-lamp-authz/lake-lamp-deployment-event-worker.service index 48b133a..e2a0f35 100644 --- a/server-tools/lake-lamp-authz/lake-lamp-deployment-event-worker.service +++ b/server-tools/lake-lamp-authz/lake-lamp-deployment-event-worker.service @@ -14,7 +14,7 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/lib/guanghu/deployment-events /var/lib/guanghu/architecture-provision /opt/guanghu/architecture-releases /etc/systemd/system +ReadWritePaths=/var/lib/guanghu/deployment-events /var/lib/guanghu/architecture-provision /opt/guanghu/architecture-releases /opt/guanghu/ai-discovery /etc/systemd/system LockPersonality=true [Install] diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js index 2d1dd25..81d671a 100644 --- a/server-tools/lake-lamp-authz/server.test.js +++ b/server-tools/lake-lamp-authz/server.test.js @@ -312,7 +312,7 @@ test("deployment is dispatched only by an explicit approved second signal", asyn const common = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops" }; const map = await (await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) })).json(); await fetch(`${base}/api/navigation-map/ack`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, map_hash: map.map_hash }) }); - const dispatch = await fetch(`${base}/api/deployment/dispatch`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha, resource, manifest: "deployment/requests/GLS-0239.json" }) }); + const dispatch = await fetch(`${base}/api/deployment/dispatch`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha, resource, manifest: "deployment/requests/GLS-0239-DEPLOY.json" }) }); assert.equal(dispatch.status, 202); assert.equal((await dispatch.json()).receipt.state, "queued"); assert.equal(fs.readdirSync(path.join(dir, "queue")).length, 1);