diff --git a/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp index beaa5cb..b2e6091 100644 --- a/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp +++ b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp @@ -1,19 +1,21 @@ # HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL -状态:本地实现候选,尚未部署;当前现实层不得宣称可派发 +状态:人工合并自动部署桥实现候选;服务器常驻部署Agent已在线,合并门待本提交部署与实测 -语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定: +普通推送本身不会产生部署事件。AI把代码和部署清单推入开发分支后,只有冰朔在光湖代码频道人工合并到 `main`,合并门才把该次合并实际改动的部署清单转换为 `guanghu.deployment-event/v1`。事件必须绑定: - 代码频道仓库、分支和完整提交 SHA; - `REQUEST-ID@完整提交SHA` 的不可变资源; - `deployment/requests/` 下的部署清单。 -语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单。 +Forgejo以HMAC-SHA256向 `/authz/api/deployment/forgejo-merge` 发送 `pull_request` 事件。合并门只接受 `action=closed`、`merged=true`、`merged_by=bingshuo`、目标仓库为 `bingshuo/guanghu-ice-heart` 且目标分支为 `main` 的事件;随后通过Forgejo只读API取得该PR改动文件。只有 `deployment/requests/.json` 被新增或修改时才写入队列。常驻部署 Agent 读取同一提交中的清单,执行备份、部署、健康检查与回滚。 -没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。 +人工合并本身就是本次部署批准,不再重复创建 `dispatch-approved-deployment` 工单。原 `/api/deployment/dispatch` 保留为没有代码频道合并界面时的兼容/灾备入口,不再作为正常发布流程。 + +没有部署清单的合并照常入库但不部署;普通push、关闭未合并、非登记合并者、错误签名、仓库/分支不匹配和无效事件均不会部署。服务器不扫描仓库猜测部署;同一Forgejo delivery与清单通过持久去重标记最多入队一次。 `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 或云控制台,也不得发送一张服务器根本不会执行的部署工单。 +首次安装合并门时,由冰朔已授权且持有本机专用密钥的当前实例,按不可变候选提交、完整备份、健康检查和服务器私有回执完成一次性引导;不得为此绕回工单。引导完成后,正常发布只走“AI开发分支 → 合并请求 → 冰朔人工合并 → 自动部署与回执”。 diff --git a/deployment/requests/HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806.json b/deployment/requests/HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806.json new file mode 100644 index 0000000..5fe43f4 --- /dev/null +++ b/deployment/requests/HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806.json @@ -0,0 +1,104 @@ +{ + "schema": "guanghu.existing-service-update-request/v1", + "request_id": "HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806", + "target_node": "JD-FD-PRIMARY", + "status": "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING", + "purpose": { + "what": "把光湖代码频道中由冰朔人工完成的main合并转换为不可变部署事件,交给京东常驻部署Agent执行。", + "why": "服务器已经具备清单校验、备份、部署、健康检查、回滚和回执能力,但代码频道的合并事件尚未接到部署队列,导致每次合并后仍需再次派发。", + "success": "仅Forgejo已合并PR且merged_by=bingshuo、目标仓库和分支匹配、改动包含deployment/requests下的有效清单时入队;普通push、关闭未合并、错误签名、其他合并者和重复投递均不产生第二次部署。", + "out_of_scope": [ + "不因普通Git push自动部署", + "不执行PR或仓库提供的命令", + "不允许清单选择目标服务器或扩大权限", + "不修改光湖OS磁盘、内核、引导或启动状态" + ] + }, + "module": { + "unit": "lake-lamp-authz.service", + "run_user": "guanghu-authz", + "install_root": "/opt/guanghu/lake-lamp-authz", + "environment_files": [ + "/etc/guanghu/secrets/lake-lamp/authorization.env" + ], + "writable_paths": [ + "/var/lib/guanghu/lake-lamp-authz", + "/var/lib/guanghu/repo-authorizations", + "/var/lib/guanghu/repo-push-uploads", + "/var/lib/guanghu/deployment-events", + "/var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git", + "/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git", + "/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git" + ] + }, + "service_update": { + "kind": "existing-systemd-service", + "require_existing_unit": true, + "required_existing_files": [ + "server.js", + "deployment-event.js" + ] + }, + "unit_source": "server-tools/lake-lamp-authz/lake-lamp-authz.service", + "files": [ + { + "source": "server-tools/lake-lamp-authz/server.js", + "destination": "server.js", + "mode": "0644" + }, + { + "source": "server-tools/lake-lamp-authz/deployment-event.js", + "destination": "deployment-event.js", + "mode": "0644" + }, + { + "source": "server-tools/lake-lamp-authz/forgejo-merge-deployment.js", + "destination": "forgejo-merge-deployment.js", + "mode": "0644" + } + ], + "runtime_check": { + "url": "http://127.0.0.1:3921/health", + "expected": { + "ok": true, + "service": "lake-lamp-authz", + "merge_deployment_gate": { + "configured": true, + "mode": "human-merged-pull-request-only" + } + } + }, + "acceptance_checks": [ + { + "url": "http://127.0.0.1:3921/api/public/capabilities", + "expected": { + "schema": "guanghu.lake-lamp-public-workorder/v1" + } + } + ], + "bootstrap": { + "one_time": true, + "reason": "本清单合并前合并门尚不存在;首次由当前已授权实例按完整候选提交和同一回滚执行器安装,之后由人工合并自动触发。", + "webhook_target": "https://guanghulab.com/authz/api/deployment/forgejo-merge", + "webhook_event": "pull_request", + "secret_location": "/etc/guanghu/secrets/lake-lamp/authorization.env", + "secret_committed": false + }, + "rollback": { + "automatic_on_failure": true, + "scope": "恢复server.js、deployment-event.js、原systemd单元并移除新增门模块,然后重启旧服务", + "receipt_required": true + }, + "security": { + "hmac_sha256_required": true, + "allowed_repository": "bingshuo/guanghu-ice-heart", + "allowed_branch": "main", + "allowed_mergers": [ + "bingshuo" + ], + "max_deployment_manifests_per_merge": 5, + "delivery_idempotency": true, + "repository_commands_executed": false, + "no_secrets": true + } +} diff --git a/server-tools/lake-lamp-authz/architecture-provision-broker.js b/server-tools/lake-lamp-authz/architecture-provision-broker.js index d743ce1..ff65030 100644 --- a/server-tools/lake-lamp-authz/architecture-provision-broker.js +++ b/server-tools/lake-lamp-authz/architecture-provision-broker.js @@ -66,6 +66,16 @@ function validateServiceUpdateManifest(manifest, unit) { 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"); } + const environmentFiles = manifest.module.environment_files || []; + const writablePaths = manifest.module.writable_paths || []; + if (!Array.isArray(environmentFiles) || environmentFiles.some(item => + !/^\/etc\/guanghu\/(?:secrets|persona-secrets)\/[A-Za-z0-9._/-]+$/.test(String(item || "")) + || String(item).split("/").includes("..") + )) throw new Error("invalid_update_environment_files"); + if (!Array.isArray(writablePaths) || writablePaths.some(item => + !/^-?\/var\/lib\/guanghu\/[A-Za-z0-9._/-]+$/.test(String(item || "")) + || String(item).split("/").includes("..") + )) throw new Error("invalid_update_writable_paths"); return { kind: "existing-service-update", unit, @@ -76,6 +86,8 @@ function validateServiceUpdateManifest(manifest, unit) { sourcePaths: [unitSource, ...manifest.files.map(item => item.source)], runtimeCheck: check, acceptanceChecks, + environmentFiles, + writablePaths, }; } @@ -109,14 +121,26 @@ function validateUnit(text, expectedUser = "guanghu", policy = {}) { return value; } -function validateUpdateUnit(text, expectedUser, installRoot) { +function validateUpdateUnit(text, expectedUser, installRoot, policy = {}) { 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"); + if (/^(SupplementaryGroups|AmbientCapabilities|BindPaths|BindReadOnlyPaths|RootDirectory|RootImage|DeviceAllow)=/m.test(value)) throw new Error("privileged_unit_directive_forbidden"); + const environmentFiles = Array.isArray(policy.environment_files) ? policy.environment_files : []; + const writablePaths = Array.isArray(policy.writable_paths) ? policy.writable_paths : []; + for (const match of value.matchAll(/^EnvironmentFile=(.+)$/gm)) { + const candidate = String(match[1]).replace(/^-/, ""); + if (!environmentFiles.includes(candidate) || !/^\/etc\/guanghu\/(?:secrets|persona-secrets)\//.test(candidate)) throw new Error("environment_file_not_declared"); + } + for (const match of value.matchAll(/^ReadWritePaths=(.+)$/gm)) { + for (const item of declaredPaths(match[1])) { + const candidate = item.replace(/^-/, ""); + if (!candidate.startsWith("/var/lib/guanghu/") || !pathAllowed(item, writablePaths)) throw new Error("writable_path_not_declared"); + } + } return value; } @@ -198,7 +222,12 @@ async function updateExistingService(context) { } 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); + const unitText = validateUpdateUnit( + fs.readFileSync(path.join(releaseRoot, checked.unitSource), "utf8"), + String(manifest.module.run_user || ""), + checked.installRoot, + { environment_files: checked.environmentFiles, writable_paths: checked.writablePaths }, + ); fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); backupFile(installedUnit, path.join(backupDir, "systemd", checked.unit), backups); for (const item of checked.files) { 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 a8aec75..43299a7 100644 --- a/server-tools/lake-lamp-authz/architecture-provision-broker.test.js +++ b/server-tools/lake-lamp-authz/architecture-provision-broker.test.js @@ -52,6 +52,31 @@ 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("existing service update permits only declared Guanghu state and secret paths", () => { + const unit = `[Service] +User=guanghu-authz +Group=guanghu-authz +WorkingDirectory=/opt/guanghu/lake-lamp-authz +ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/server.js +EnvironmentFile=/etc/guanghu/secrets/lake-lamp/authorization.env +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/opt/guanghu/lake-lamp-authz +ReadWritePaths=/var/lib/guanghu/lake-lamp-authz -/var/lib/guanghu/deployment-events +`; + const policy = { + environment_files: ["/etc/guanghu/secrets/lake-lamp/authorization.env"], + writable_paths: ["/var/lib/guanghu/lake-lamp-authz", "/var/lib/guanghu/deployment-events"], + }; + assert.equal(validateUpdateUnit(unit, "guanghu-authz", "/opt/guanghu/lake-lamp-authz", policy), unit); + assert.throws( + () => validateUpdateUnit(unit.replace("/var/lib/guanghu/lake-lamp-authz", "/etc/systemd/system"), "guanghu-authz", "/opt/guanghu/lake-lamp-authz", policy), + /writable_path_not_declared/, + ); +}); + 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"))); diff --git a/server-tools/lake-lamp-authz/authorization.env.example b/server-tools/lake-lamp-authz/authorization.env.example index 6a55b1b..100ca75 100644 --- a/server-tools/lake-lamp-authz/authorization.env.example +++ b/server-tools/lake-lamp-authz/authorization.env.example @@ -34,3 +34,6 @@ ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo ARCHITECTURE_PROVISION_RELEASES_DIR=/opt/guanghu/architecture-releases ARCHITECTURE_PROVISION_UNIT_DIR=/etc/systemd/system ARCHITECTURE_PROVISION_RECEIPTS_DIR=/var/lib/guanghu/architecture-provision/receipts +LAKE_LAMP_FORGEJO_MERGE_SECRET=SET_RANDOM_32_BYTE_VALUE_IN_PRIVATE_SERVER_FILE +LAKE_LAMP_FORGEJO_MERGE_MERGERS=bingshuo +LAKE_LAMP_FORGEJO_API_BASE=http://127.0.0.1:3340/api/v1 diff --git a/server-tools/lake-lamp-authz/deployment-event.js b/server-tools/lake-lamp-authz/deployment-event.js index a1cdf04..e2747ce 100644 --- a/server-tools/lake-lamp-authz/deployment-event.js +++ b/server-tools/lake-lamp-authz/deployment-event.js @@ -11,9 +11,30 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) { const invalid = validateIntent(intent, push, context); if (invalid) return { state: "rejected", diagnostic_code: invalid }; fs.mkdirSync(queueDir, { recursive: true, mode: 0o750 }); + const requestedEventId = String(context.event_id || ""); + if (requestedEventId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[45][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestedEventId)) { + return { state: "rejected", diagnostic_code: "deployment_event_id_invalid" }; + } + const eventId = requestedEventId || crypto.randomUUID(); + const dedupeKey = String(context.dedupe_key || ""); + let marker = ""; + if (dedupeKey) { + const dedupeDir = path.join(queueDir, ".dedupe"); + fs.mkdirSync(dedupeDir, { recursive: true, mode: 0o750 }); + marker = path.join(dedupeDir, `${crypto.createHash("sha256").update(dedupeKey).digest("hex")}.json`); + try { + fs.writeFileSync(marker, JSON.stringify({ event_id: eventId, state: "reserved" }), { mode: 0o640, flag: "wx" }); + } catch (error) { + if (error.code === "EEXIST") { + const prior = JSON.parse(fs.readFileSync(marker, "utf8")); + return { state: "duplicate", event_id: String(prior.event_id || eventId) }; + } + throw error; + } + } const event = { schema: "guanghu.deployment-event/v1", - event_id: crypto.randomUUID(), + event_id: eventId, created_at: Date.now() / 1000, state: "queued_for_resident_agent", repo: push.repo, branch: push.branch, commit_sha: push.commit_sha, @@ -28,8 +49,21 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) { }; const target = path.join(queueDir, `${event.created_at}-${event.event_id}.json`); const temporary = `${target}.${process.pid}.tmp`; - fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o640 }); - fs.renameSync(temporary, target); + try { + fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o640, flag: "wx" }); + fs.renameSync(temporary, target); + if (marker) { + const markerTemporary = `${marker}.${process.pid}.tmp`; + fs.writeFileSync(markerTemporary, JSON.stringify({ event_id: eventId, state: "queued", queued_at: event.created_at }), { mode: 0o640, flag: "wx" }); + fs.renameSync(markerTemporary, marker); + } + } catch (error) { + try { fs.unlinkSync(temporary); } catch {} + if (marker) { + try { fs.unlinkSync(marker); } catch {} + } + throw error; + } return { state: event.state, event_id: event.event_id }; } function validateIntent(intent, push, context = {}) { diff --git a/server-tools/lake-lamp-authz/forgejo-merge-deployment.js b/server-tools/lake-lamp-authz/forgejo-merge-deployment.js new file mode 100644 index 0000000..afad629 --- /dev/null +++ b/server-tools/lake-lamp-authz/forgejo-merge-deployment.js @@ -0,0 +1,174 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { enqueueDeploymentEvent } = require("./deployment-event"); + +const DEFAULT_REPOSITORY = "bingshuo/guanghu-ice-heart"; +const DEFAULT_BRANCH = "main"; +const DEFAULT_TARGET = "JD-FD-PRIMARY"; +const MANIFEST_PATTERN = /^deployment\/requests\/([A-Z0-9][A-Z0-9._-]{5,119})\.json$/; + +function safeEqualHex(expected, supplied) { + if (!/^[a-f0-9]{64}$/i.test(String(supplied || ""))) return false; + const left = Buffer.from(expected, "hex"); + const right = Buffer.from(String(supplied), "hex"); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function verifyForgejoSignature(secret, rawBody, supplied) { + if (String(secret || "").length < 32) return false; + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + return safeEqualHex(expected, String(supplied || "").replace(/^sha256=/i, "")); +} + +function deterministicEventId(value) { + const bytes = crypto.createHash("sha256").update(String(value)).digest().subarray(0, 16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function parseMergedPullRequest(payload, eventName, policy = {}) { + if (eventName !== "pull_request") return { state: "ignored", diagnostic_code: "not_pull_request_event" }; + if (!payload || payload.action !== "closed" || payload.pull_request?.merged !== true) { + return { state: "ignored", diagnostic_code: "pull_request_not_merged" }; + } + const pull = payload.pull_request; + const repository = String(payload.repository?.full_name || pull.base?.repo?.full_name || "").toLowerCase(); + const branch = String(pull.base?.ref || ""); + const commit = String(pull.merge_commit_sha || "").toLowerCase(); + const merger = String(pull.merged_by?.login || pull.merged_by?.username || ""); + const number = Number(pull.number || pull.index || payload.number); + const expectedRepository = String(policy.repository || DEFAULT_REPOSITORY).toLowerCase(); + const expectedBranch = String(policy.branch || DEFAULT_BRANCH); + const allowedMergers = policy.allowedMergers || []; + if (repository !== expectedRepository || branch !== expectedBranch) { + return { state: "rejected", diagnostic_code: "merge_target_not_allowed" }; + } + if (!/^[0-9a-f]{40}$/.test(commit) || !Number.isSafeInteger(number) || number < 1) { + return { state: "rejected", diagnostic_code: "merge_identity_invalid" }; + } + if (!merger || !allowedMergers.includes(merger)) { + return { state: "rejected", diagnostic_code: "human_merger_not_allowed" }; + } + return { state: "accepted", repository, branch, commit, merger, number }; +} + +async function listPullRequestFiles(merge, options = {}) { + const fetchImpl = options.fetchImpl || fetch; + const base = String(options.forgejoApiBase || "http://127.0.0.1:3340/api/v1").replace(/\/$/, ""); + const files = []; + for (let page = 1; page <= 5; page += 1) { + const url = `${base}/repos/${merge.repository}/pulls/${merge.number}/files?limit=100&page=${page}`; + const response = await fetchImpl(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error(`forgejo_pull_files_unavailable:${response.status}`); + const batch = await response.json(); + if (!Array.isArray(batch)) throw new Error("forgejo_pull_files_invalid"); + files.push(...batch); + if (batch.length < 100) return files; + } + throw new Error("forgejo_pull_files_limit_exceeded"); +} + +function deploymentManifests(files) { + const selected = []; + for (const file of files || []) { + const name = String(file?.filename || ""); + const match = name.match(MANIFEST_PATTERN); + if (!match || file.status === "deleted") continue; + selected.push({ path: name, requestId: match[1] }); + } + const unique = [...new Map(selected.map(item => [item.path, item])).values()]; + if (unique.length > 5) throw new Error("too_many_deployment_manifests"); + return unique; +} + +async function processForgejoMergeWebhook(input, options = {}) { + const rawBody = Buffer.isBuffer(input.rawBody) ? input.rawBody : Buffer.from(String(input.rawBody || "")); + if (!verifyForgejoSignature(options.secret, rawBody, input.signature)) { + return { ok: false, status: 401, state: "rejected", diagnostic_code: "forgejo_signature_invalid" }; + } + let payload; + try { payload = JSON.parse(rawBody.toString("utf8")); } + catch { return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_payload_invalid" }; } + const merge = parseMergedPullRequest(payload, String(input.eventName || ""), { + repository: options.repository, + branch: options.branch, + allowedMergers: options.allowedMergers || [], + }); + if (merge.state === "ignored") return { ok: true, status: 202, ...merge, queued: 0 }; + if (merge.state !== "accepted") return { ok: false, status: 409, ...merge }; + + let manifests; + try { + const files = await (options.listFiles || (value => listPullRequestFiles(value, options)))(merge); + manifests = deploymentManifests(files); + } catch (error) { + return { ok: false, status: 502, state: "rejected", diagnostic_code: String(error.message || "forgejo_pull_files_failed") }; + } + if (manifests.length === 0) { + return { ok: true, status: 202, state: "accepted_no_deployment_manifest", queued: 0, commit_sha: merge.commit }; + } + + const delivery = String(input.delivery || ""); + if (!/^[A-Za-z0-9._:-]{8,160}$/.test(delivery)) { + return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_delivery_invalid" }; + } + const deploymentRepositories = options.deploymentRepositories || {}; + const queue = options.enqueue || enqueueDeploymentEvent; + const events = []; + for (const manifest of manifests) { + const dedupeKey = `${delivery}:${merge.repository}:${merge.commit}:${manifest.requestId}`; + const eventId = deterministicEventId(dedupeKey); + const queued = queue({ + schema: "guanghu.deployment-intent/v1", + repo: merge.repository, + branch: merge.branch, + commit_sha: merge.commit, + resource: `${manifest.requestId}@${merge.commit}`, + manifest: manifest.path, + workorder_id: `forgejo-pr:${merge.number}:${delivery}`, + }, { + repo: merge.repository, + branch: merge.branch, + commit_sha: merge.commit, + }, options.queueDir, { + authorizer_id: "ICE-GL∞", + persona_id: "SYS-GLW-ZY-EXEC-0001", + execution_runtime_id: "HLCC-MERGE-DEPLOY-GATE-001", + target: options.target || DEFAULT_TARGET, + registry: deploymentRepositories, + event_id: eventId, + dedupe_key: dedupeKey, + }); + if (!["queued_for_resident_agent", "duplicate"].includes(queued.state)) { + return { ok: false, status: 409, state: "rejected", diagnostic_code: queued.diagnostic_code || "deployment_event_rejected" }; + } + events.push({ request_id: manifest.requestId, event_id: queued.event_id, state: queued.state }); + } + return { + ok: true, + status: 202, + state: events.every(item => item.state === "duplicate") ? "duplicate" : "queued_for_resident_agent", + repository: merge.repository, + branch: merge.branch, + commit_sha: merge.commit, + pull_request: merge.number, + merger: merge.merger, + queued: events.filter(item => item.state === "queued_for_resident_agent").length, + events, + }; +} + +module.exports = { + deploymentManifests, + deterministicEventId, + listPullRequestFiles, + parseMergedPullRequest, + processForgejoMergeWebhook, + verifyForgejoSignature, +}; diff --git a/server-tools/lake-lamp-authz/forgejo-merge-deployment.test.js b/server-tools/lake-lamp-authz/forgejo-merge-deployment.test.js new file mode 100644 index 0000000..5cdbb86 --- /dev/null +++ b/server-tools/lake-lamp-authz/forgejo-merge-deployment.test.js @@ -0,0 +1,105 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { processForgejoMergeWebhook } = require("./forgejo-merge-deployment"); + +const secret = "s".repeat(64); +const commit = "a".repeat(40); + +function signed(payload, overrides = {}) { + const rawBody = Buffer.from(JSON.stringify(payload)); + return { + rawBody, + signature: crypto.createHmac("sha256", secret).update(rawBody).digest("hex"), + eventName: "pull_request", + delivery: "delivery-12345678", + ...overrides, + }; +} + +function mergedPayload(overrides = {}) { + return { + action: "closed", + repository: { full_name: "bingshuo/guanghu-ice-heart" }, + pull_request: { + number: 42, + merged: true, + merge_commit_sha: commit, + merged_by: { login: "bingshuo" }, + base: { ref: "main" }, + ...overrides, + }, + }; +} + +test("human merged pull request queues only changed deployment manifests", async () => { + const calls = []; + const result = await processForgejoMergeWebhook(signed(mergedPayload()), { + secret, + allowedMergers: ["bingshuo"], + deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} }, + queueDir: "/not-used", + listFiles: async () => [ + { filename: "server-tools/lake-lamp-authz/server.js", status: "modified" }, + { filename: "deployment/requests/HLCC-MERGE-DEPLOY-20260806.json", status: "added" }, + ], + enqueue: (...args) => { + calls.push(args); + return { state: "queued_for_resident_agent", event_id: args[3].event_id }; + }, + }); + assert.equal(result.state, "queued_for_resident_agent"); + assert.equal(result.queued, 1); + assert.equal(calls[0][0].resource, `HLCC-MERGE-DEPLOY-20260806@${commit}`); + assert.equal(calls[0][3].authorizer_id, "ICE-GL∞"); + assert.equal(calls[0][3].target, "JD-FD-PRIMARY"); +}); + +test("ordinary push or closed unmerged request never queues", async () => { + const push = await processForgejoMergeWebhook(signed(mergedPayload(), { eventName: "push" }), { + secret, + allowedMergers: ["bingshuo"], + }); + assert.equal(push.state, "ignored"); + const closed = await processForgejoMergeWebhook(signed(mergedPayload({ merged: false })), { + secret, + allowedMergers: ["bingshuo"], + }); + assert.equal(closed.state, "ignored"); +}); + +test("unregistered merger and invalid signature fail closed", async () => { + const merger = await processForgejoMergeWebhook(signed(mergedPayload({ merged_by: { login: "robot" } })), { + secret, + allowedMergers: ["bingshuo"], + }); + assert.equal(merger.diagnostic_code, "human_merger_not_allowed"); + const signature = await processForgejoMergeWebhook(signed(mergedPayload(), { signature: "0".repeat(64) }), { + secret, + allowedMergers: ["bingshuo"], + }); + assert.equal(signature.status, 401); +}); + +test("delivery retries are idempotent in the real queue", async () => { + const queueDir = fs.mkdtempSync(path.join(os.tmpdir(), "merge-deploy-")); + const options = { + secret, + allowedMergers: ["bingshuo"], + deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} }, + queueDir, + listFiles: async () => [ + { filename: "deployment/requests/HLCC-MERGE-DEPLOY-20260806.json", status: "added" }, + ], + }; + const first = await processForgejoMergeWebhook(signed(mergedPayload()), options); + const second = await processForgejoMergeWebhook(signed(mergedPayload()), options); + assert.equal(first.queued, 1); + assert.equal(second.state, "duplicate"); + assert.equal(fs.readdirSync(queueDir).filter(name => name.endsWith(".json")).length, 1); +}); diff --git a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh index 5236a2d..ebc6cbe 100755 --- a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh +++ b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh @@ -27,6 +27,7 @@ required_source_files=( deployment-event.js deployment-event-worker.js deployment-source-policy.js + forgejo-merge-deployment.js guanghu-router.js repo-push-broker.js hololake-session.js @@ -135,6 +136,11 @@ append_setting HOLOLAKE_OTP_REQUEST_LIMIT 6 append_setting HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH "$knowledge_repo" append_setting HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES 134217728 append_setting HOLOLAKE_AI_PROVIDERS_FILE "$provider_registry" +if ! grep -q '^LAKE_LAMP_FORGEJO_MERGE_SECRET=' "$authorization_env"; then + append_setting LAKE_LAMP_FORGEJO_MERGE_SECRET "$(/usr/bin/openssl rand -hex 32)" +fi +append_setting LAKE_LAMP_FORGEJO_MERGE_MERGERS bingshuo +append_setting LAKE_LAMP_FORGEJO_API_BASE http://127.0.0.1:3340/api/v1 chmod 0600 "$authorization_env" if [[ ! -f "$provider_registry" ]]; then diff --git a/server-tools/lake-lamp-authz/lake-lamp-authz.service b/server-tools/lake-lamp-authz/lake-lamp-authz.service index fc8d350..92ae8f1 100644 --- a/server-tools/lake-lamp-authz/lake-lamp-authz.service +++ b/server-tools/lake-lamp-authz/lake-lamp-authz.service @@ -16,6 +16,7 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true +ReadOnlyPaths=/opt/guanghu/lake-lamp-authz ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/deployment-events /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git -/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git ReadOnlyPaths=-/etc/guanghu/secrets/hololake-ai-providers.json RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index 508db42..d4937c0 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -9,6 +9,7 @@ const { MapGate } = require("./map-gate"); const { sendSmtpMail } = require("./smtp-mailer"); const { executeRegisteredAction } = require("./action-client"); const { enqueueDeploymentEvent } = require("./deployment-event"); +const { processForgejoMergeWebhook } = require("./forgejo-merge-deployment"); const { GuanghuRouter, loadDevices } = require("./guanghu-router"); const { HoloLakeSessionManager } = require("./hololake-session"); const { GhdrAuthorizer } = require("./ghdr-authorizer"); @@ -177,6 +178,8 @@ function createApp(options = {}) { ); const deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events"; const deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json"; + const forgejoMergeSecret = String(options.forgejoMergeSecret || process.env.LAKE_LAMP_FORGEJO_MERGE_SECRET || ""); + const forgejoMergeMergers = options.forgejoMergeMergers || splitCsv(process.env.LAKE_LAMP_FORGEJO_MERGE_MERGERS || "bingshuo"); const executeAction = options.executeAction || executeRegisteredAction; let ghdrAuthorizer = null; const getGhdrAuthorizer = options.getGhdrAuthorizer || (() => { @@ -296,6 +299,10 @@ function createApp(options = {}) { knowledge_snapshot: Boolean(hololakeKnowledgeProvider), ai_gateway: Boolean(hololakeAiGateway), }, + merge_deployment_gate: { + configured: forgejoMergeSecret.length >= 32, + mode: "human-merged-pull-request-only", + }, }); if (req.method === "GET" && url.pathname === "/api/public/capabilities") return json(res, 200, { schema: "guanghu.lake-lamp-public-workorder/v1", @@ -1087,6 +1094,26 @@ function createApp(options = {}) { } } + if (req.method === "POST" && url.pathname === "/api/deployment/forgejo-merge") { + if (forgejoMergeSecret.length < 32) return json(res, 503, failure("forgejo_merge_gate_unconfigured")); + const rawBody = await readRawBody(req, 1024 * 1024); + const result = await processForgejoMergeWebhook({ + rawBody, + signature: req.headers["x-forgejo-signature"] || req.headers["x-gitea-signature"], + eventName: req.headers["x-forgejo-event"] || req.headers["x-gitea-event"], + delivery: req.headers["x-forgejo-delivery"] || req.headers["x-gitea-delivery"], + }, { + secret: forgejoMergeSecret, + allowedMergers: forgejoMergeMergers, + queueDir: deploymentQueueDir, + deploymentRepositories: options.deploymentRepositories || loadDeploymentRepositories(deploymentRegistryFile), + forgejoApiBase: options.forgejoApiBase || process.env.LAKE_LAMP_FORGEJO_API_BASE || "http://127.0.0.1:3340/api/v1", + listFiles: options.listForgejoPullFiles, + enqueue: options.enqueueMergeDeploymentEvent, + }); + return json(res, result.status || (result.ok ? 202 : 400), result); + } + if (req.method === "POST" && url.pathname === "/api/deployment/dispatch") { const body = await readJson(req); if (!body) return json(res, 400, failure("invalid_json")); @@ -1315,6 +1342,26 @@ function readJson(req, maxBytes = 32 * 1024) { }); } +function readRawBody(req, maxBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let received = 0; + req.on("data", chunk => { + received += chunk.length; + if (received > maxBytes) { + const error = new Error("body too large"); + error.code = "BODY_TOO_LARGE"; + reject(error); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + function readBinaryBody(req, destination, maxBytes, options = {}) { return new Promise((resolve, reject) => { let received = 0; diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js index 7f68723..f64feab 100644 --- a/server-tools/lake-lamp-authz/server.test.js +++ b/server-tools/lake-lamp-authz/server.test.js @@ -877,6 +877,52 @@ test("deployment is dispatched only by an explicit approved second signal", asyn } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); +test("Forgejo human merge webhook queues deployment without a second workorder", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-merge-deploy-")); + const secret = "m".repeat(64); + const commit = "b".repeat(40); + const payload = { + action: "closed", + repository: { full_name: "bingshuo/guanghu-ice-heart" }, + pull_request: { + number: 7, + merged: true, + merge_commit_sha: commit, + merged_by: { login: "bingshuo" }, + base: { ref: "main" }, + }, + }; + const body = JSON.stringify(payload); + const signature = crypto.createHmac("sha256", secret).update(body).digest("hex"); + try { + await withServer(async ({ base }) => { + const response = await fetch(`${base}/api/deployment/forgejo-merge`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-forgejo-event": "pull_request", + "x-forgejo-delivery": "delivery-server-test-001", + "x-forgejo-signature": signature, + }, + body, + }); + assert.equal(response.status, 202); + const result = await response.json(); + assert.equal(result.state, "queued_for_resident_agent"); + assert.equal(result.queued, 1); + assert.equal(fs.readdirSync(path.join(dir, "queue")).filter(name => name.endsWith(".json")).length, 1); + }, { + forgejoMergeSecret: secret, + forgejoMergeMergers: ["bingshuo"], + deploymentQueueDir: path.join(dir, "queue"), + deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} }, + listForgejoPullFiles: async () => [ + { filename: "deployment/requests/HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806.json", status: "added" }, + ], + }); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test("request endpoint rejects direct email target switching and unknown actions", async () => { await withServer(async ({ base }) => { const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };