deploy(ai-discovery): add safe existing-service update path
This commit is contained in:
parent
7c3c8e9238
commit
2739104088
14 changed files with 427 additions and 26 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL
|
# HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL
|
||||||
|
|
||||||
状态:本地实现候选,尚未部署
|
状态:本地实现候选,尚未部署;当前现实层不得宣称可派发
|
||||||
|
|
||||||
语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定:
|
语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定:
|
||||||
|
|
||||||
|
|
@ -11,3 +11,9 @@
|
||||||
语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单。
|
语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单。
|
||||||
|
|
||||||
没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。
|
没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。
|
||||||
|
|
||||||
|
`resource` 中的 `REQUEST-ID` 必须与清单文件名完全一致:`deployment/requests/<REQUEST-ID>.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 或云控制台,也不得发送一张服务器根本不会执行的部署工单。
|
||||||
|
|
|
||||||
108
deployment/requests/AI-DISCOVERY-ICE-P-ROUTE-20260727.json
Normal file
108
deployment/requests/AI-DISCOVERY-ICE-P-ROUTE-20260727.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
# 新架构首次部署请求
|
# 部署请求
|
||||||
|
|
||||||
本目录中的 `guanghu.architecture-provision-request/v1` 文件是新系统架构第一次进入服务器的结构化清单。
|
本目录同时容纳两类严格区分的清单:
|
||||||
|
|
||||||
|
- `guanghu.architecture-provision-request/v1`:服务器上尚不存在的新架构单元首次进入现实层;
|
||||||
|
- `guanghu.existing-service-update-request/v1`:更新已经存在的 systemd 服务,必须先备份全部声明目标,重启后做回环健康检查,失败则恢复全部文件和旧单元。
|
||||||
|
|
||||||
|
不得用“首次部署”清单替代已有服务升级,也不得把升级失败降级为继续运行未验证的新文件。
|
||||||
|
|
||||||
固定流程:
|
固定流程:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ WorkingDirectory=/opt/guanghu/ai-discovery
|
||||||
Environment=GUANGHU_AI_HOST=127.0.0.1
|
Environment=GUANGHU_AI_HOST=127.0.0.1
|
||||||
Environment=GUANGHU_AI_PORT=3922
|
Environment=GUANGHU_AI_PORT=3922
|
||||||
Environment=GUANGHU_REPOSITORY_MAP=/opt/guanghu/ai-discovery/repository-route-map.json
|
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
|
ExecStart=/usr/bin/node /opt/guanghu/ai-discovery/server.js
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
|
||||||
|
|
@ -102,23 +102,27 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
||||||
- `restore-code-channel-owner-login` 只把京东本机旧第五域数据库中的 `bingshuo` 密码摘要恢复到新代码频道,同时校正启用、管理员和禁止登录状态。它不读取明文密码,不修改 SSH,并在变更前使用 SQLite backup API 生成一致性备份。
|
- `restore-code-channel-owner-login` 只把京东本机旧第五域数据库中的 `bingshuo` 密码摘要恢复到新代码频道,同时校正启用、管理员和禁止登录状态。它不读取明文密码,不修改 SSH,并在变更前使用 SQLite backup API 生成一致性备份。
|
||||||
- 登录问题先执行 `inspect-code-channel-owner-auth`。只有确认新旧频道凭证不一致时,才申请后一项恢复动作;不得绕到新加坡灾备节点,不得向冰朔索要密码。
|
- 登录问题先执行 `inspect-code-channel-owner-auth`。只有确认新旧频道凭证不一致时,才申请后一项恢复动作;不得绕到新加坡灾备节点,不得向冰朔索要密码。
|
||||||
|
|
||||||
## 新架构首次部署
|
## 首次部署与已有服务更新
|
||||||
|
|
||||||
旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单:
|
旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node request-workorder.js \
|
node request-workorder.js \
|
||||||
--url https://guanghulab.com/authz \
|
--url https://guanghulab.com/authz \
|
||||||
--persona ICE-GL-ZY001 \
|
--persona ICE-P-ZY001 \
|
||||||
--name 铸渊 \
|
--name 铸渊 \
|
||||||
--target JD-FD-PRIMARY \
|
--target JD-FD-PRIMARY \
|
||||||
--scope server-ops \
|
--scope server-ops \
|
||||||
--action provision-approved-architecture \
|
--action provision-approved-architecture \
|
||||||
--resource 'REQUEST-ID@40位提交SHA' \
|
--resource 'REQUEST-ID@40位提交SHA' \
|
||||||
--description '首次安装已审核架构包'
|
--description '安装或更新已审核的不可变部署包'
|
||||||
```
|
```
|
||||||
|
|
||||||
邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交。执行器只读取该提交中 `deployment/requests/<REQUEST-ID>.json`,只复制清单列出的普通文件,只安装清单指定的非 root、加固 systemd 单元,并只接受回环健康检查。人格体可以使用清单声明的独立低权限账户、共享模型密钥文件和状态目录;密钥路径必须位于 `/etc/guanghu/persona-secrets/`,可写路径必须位于 `/var/lib/guanghu/personas/<运行账户>/`。覆盖旧单元前强制备份,启动或验收失败时自动恢复。说明文字不能改变部署内容,也不开放任意 shell。
|
邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交,清单路径也必须严格等于 `deployment/requests/<REQUEST-ID>.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
|
sudo bash server-tools/lake-lamp-authz/install-architecture-provisioner.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
这是最后一次需要云厂商控制台或现有系统管理通道的引导。安装完成后,未来新架构均走上面的结构化工单,不必预先把每个未来模块写进旧动作桥。
|
引导本身也是一次受控部署:应从已经存在的主人邮件批准系统管理通道执行,不得为此恢复人类 SSH 登录。若当前服务器尚未登记能够安装此运行时的固定动作,状态必须记为 `BOOTSTRAP_ACTION_NOT_DEPLOYED`,不能把仓库提交、工单批准或打开云后台冒充成运行时已部署。引导完成并读回三个服务均为 active 后,未来安装与更新才走通感桥的结构化工单。
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,59 @@ function safeRelative(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateManifest(manifest, resource) {
|
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.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 || "");
|
const unit = String(manifest.module && manifest.module.unit || "");
|
||||||
if (!/^[A-Za-z0-9_.@-]+\.service$/.test(unit)) throw new Error("invalid_unit_name");
|
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");
|
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);
|
const unitMatches = manifest.source_paths.filter(item => path.basename(item) === unit);
|
||||||
if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared");
|
if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared");
|
||||||
const check = manifest.runtime_check || {};
|
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 (!/^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");
|
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) {
|
function declaredPaths(value) {
|
||||||
|
|
@ -69,6 +109,17 @@ function validateUnit(text, expectedUser = "guanghu", policy = {}) {
|
||||||
return value;
|
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 = {}) {
|
async function provision(request, options = {}) {
|
||||||
if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" };
|
if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" };
|
||||||
const resource = parseResource(request.resource);
|
const resource = parseResource(request.resource);
|
||||||
|
|
@ -84,10 +135,17 @@ async function provision(request, options = {}) {
|
||||||
await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL);
|
await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL);
|
||||||
const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`);
|
const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`);
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
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);
|
const releaseRoot = path.join(releasesDir, resource.commit);
|
||||||
fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 });
|
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 unitSource = path.join(releaseRoot, checked.unitSource);
|
||||||
const unitText = validateUnit(fs.readFileSync(unitSource, "utf8"), String(manifest.module.run_user || ""), manifest.module).replaceAll("__RELEASE_ROOT__", releaseRoot);
|
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 });
|
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) {
|
async function prepareRepo(repoDir, commit, run, repoUrl) {
|
||||||
fs.mkdirSync(path.dirname(repoDir), { recursive: true, mode: 0o700 });
|
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]);
|
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); });
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const assert = require("node:assert/strict");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const path = require("node:path");
|
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 commit = "d".repeat(40);
|
||||||
const requestId = "GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720";
|
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/);
|
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 () => {
|
test("provision copies only declared files and verifies loopback health", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-provision-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-provision-"));
|
||||||
const repoDir = path.join(root, "repo");
|
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);
|
assert.equal(healthChecks, 2);
|
||||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
} 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 }); }
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,10 @@ async function processOne(options = {}) {
|
||||||
function validateEvent(event, registry) {
|
function validateEvent(event, registry) {
|
||||||
if (!event || event.schema !== "guanghu.deployment-event/v1" || event.state !== "queued_for_resident_agent") return "deployment_event_schema_invalid";
|
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 (!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 (!/^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);
|
return validateDeploymentSource(event, registry);
|
||||||
}
|
}
|
||||||
function writeAtomic(file, content) { const temp = `${file}.${process.pid}.tmp`; fs.writeFileSync(temp, content, { mode: 0o600 }); fs.renameSync(temp, file); }
|
function writeAtomic(file, content) { const temp = `${file}.${process.pid}.tmp`; fs.writeFileSync(temp, content, { mode: 0o600 }); fs.renameSync(temp, file); }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const path = require("node:path");
|
||||||
const { processOne } = require("./deployment-event-worker");
|
const { processOne } = require("./deployment-event-worker");
|
||||||
test("resident deployment agent consumes only an explicit immutable event and writes a receipt", async () => {
|
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);
|
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 {
|
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" }; } });
|
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");
|
assert.equal(result.state, "DEPLOYED_AND_VERIFIED");
|
||||||
|
|
@ -27,7 +27,7 @@ test("resident agent rechecks source ownership before deployment", async () => {
|
||||||
commit_sha: sha,
|
commit_sha: sha,
|
||||||
workorder_id: "order-2",
|
workorder_id: "order-2",
|
||||||
resource: `HLP-PERSONAL-DEPLOY@${sha}`,
|
resource: `HLP-PERSONAL-DEPLOY@${sha}`,
|
||||||
manifest: "deployment/requests/HLP-PERSONAL.json",
|
manifest: "deployment/requests/HLP-PERSONAL-DEPLOY.json",
|
||||||
authorizer_id: "ICE-GL∞",
|
authorizer_id: "ICE-GL∞",
|
||||||
persona_id: "AGE-TEAM-001",
|
persona_id: "AGE-TEAM-001",
|
||||||
execution_runtime_id: "SYS-GLW-ZY-EXEC-0001",
|
execution_runtime_id: "SYS-GLW-ZY-EXEC-0001",
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,10 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) {
|
||||||
function validateIntent(intent, push, context = {}) {
|
function validateIntent(intent, push, context = {}) {
|
||||||
if (intent.schema !== "guanghu.deployment-intent/v1") return "deployment_intent_schema_invalid";
|
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 (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 (!/^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) {
|
if (context.registry) {
|
||||||
return validateDeploymentSource({
|
return validateDeploymentSource({
|
||||||
repo: push.repo,
|
repo: push.repo,
|
||||||
|
|
|
||||||
|
|
@ -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 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 };
|
const sha = "a".repeat(40), push = { repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha };
|
||||||
try {
|
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(queued.state, "queued_for_resident_agent");
|
||||||
assert.equal(fs.readdirSync(queue).length, 1);
|
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 }); }
|
} finally { fs.rmSync(queue, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
test("team personas cannot dispatch Ice Shuo personal source", () => {
|
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,
|
branch: push.branch,
|
||||||
commit_sha: sha,
|
commit_sha: sha,
|
||||||
resource: `HLP-PERSONAL-DEPLOY@${sha}`,
|
resource: `HLP-PERSONAL-DEPLOY@${sha}`,
|
||||||
manifest: "deployment/requests/HLP-PERSONAL.json",
|
manifest: "deployment/requests/HLP-PERSONAL-DEPLOY.json",
|
||||||
deployment_source: personalSource,
|
deployment_source: personalSource,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,25 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
install_root=/opt/guanghu/lake-lamp-authz
|
install_root=/opt/guanghu/lake-lamp-authz
|
||||||
|
|
||||||
install -d -m 0755 "$install_root"
|
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"
|
install -m 0644 "$script_dir/$file" "$install_root/$file"
|
||||||
done
|
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-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 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 /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 daemon-reload
|
||||||
systemctl enable --now lake-lamp-architecture-provision.service
|
systemctl enable --now lake-lamp-architecture-provision.service
|
||||||
systemctl restart lake-lamp-authz.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-architecture-provision.service
|
||||||
systemctl is-active --quiet lake-lamp-authz.service
|
systemctl is-active --quiet lake-lamp-authz.service
|
||||||
|
systemctl is-active --quiet lake-lamp-deployment-event-worker.service
|
||||||
echo ARCHITECTURE_PROVISIONER_INSTALLED
|
echo ARCHITECTURE_PROVISIONER_INSTALLED
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
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
|
LockPersonality=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
|
|
@ -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 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();
|
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 }) });
|
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(dispatch.status, 202);
|
||||||
assert.equal((await dispatch.json()).receipt.state, "queued");
|
assert.equal((await dispatch.json()).receipt.state, "queued");
|
||||||
assert.equal(fs.readdirSync(path.join(dir, "queue")).length, 1);
|
assert.equal(fs.readdirSync(path.join(dir, "queue")).length, 1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue