Merge pull request 'feat/hlcc-human-merge-auto-deploy-20260806' (#2) from feat/hlcc-human-merge-auto-deploy-20260806 into main
Reviewed-on: #2
This commit is contained in:
commit
62b970ca55
12 changed files with 587 additions and 11 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
174
server-tools/lake-lamp-authz/forgejo-merge-deployment.js
Normal file
174
server-tools/lake-lamp-authz/forgejo-merge-deployment.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
105
server-tools/lake-lamp-authz/forgejo-merge-deployment.test.js
Normal file
105
server-tools/lake-lamp-authz/forgejo-merge-deployment.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" } };
|
||||
|
|
|
|||
Loading…
Reference in a new issue