From ce33364d5a41b36ddc7eb2778bda4b095b7bdc26 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 12:41:15 +0200 Subject: [PATCH] fix: gate webhook bridge self-updates --- .../unidesk-cicd/references/gitea-pac.md | 22 +++ config/platform-infra/gitea.yaml | 8 + ...atform-infra-gitea-gitops-delivery.test.ts | 15 ++ scripts/src/platform-infra-gitea.ts | 176 +++++++++++++++++- 4 files changed, 220 insertions(+), 1 deletion(-) diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index c1bce960..f4fa238a 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -10,6 +10,28 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge 自动链任一环节不通时,任务目标是修复该环节的 YAML、controller 或源码,并以修复 PR 合并后产生的新自动链事件作为验收证据。禁止用 Gitea Actions、`act_runner`、branch-follower、host worktree、Pipeline 内直连 GitHub、直接 Gitea push、人工 mirror sync、人工 PipelineRun、`trigger-current`、`webhook-test` 或自定义脚本补齐当前交付。`config/cicd-gitea-actions-poc.yaml` 只保留只读历史材料;`webhook-test` 只允许诊断连通性。 +## Webhook bridge 自举保护 + +- Argo `selfHeal` 的边界: + - 只能把 GitOps branch 已存在的 desired 恢复到运行面; + - 不能从 GitHub 获取尚未进入 Gitea branch/snapshot 的 bridge 修复 commit; + - 不得把 `selfHeal` 描述为 bridge 故障后的独立 source authority。 +- Bridge desired 更新必须经过同一 Argo Application 的 PreSync candidate gate: + - owning 配置是 `config/platform-infra/gitea.yaml#sourceAuthority.webhookSync.bridge.candidateGate`; + - candidate 使用与正式 Deployment 相同的镜像、`server.mjs` 和 ConfigMap 符号链接挂载形状; + - candidate 使用隔离的临时 inbox,只验证进程启动、durable storage readiness 与 `/healthz`; + - candidate 通过后才允许 Argo 更新正式 ConfigMap 和 `Recreate` Deployment; + - candidate 失败时必须保留现有 ready bridge,不能继续替换单写者。 +- Candidate gate 不是第二触发或恢复路径: + - 不读取 GitHub branch; + - 不写 Gitea branch/snapshot; + - 不创建业务 PipelineRun; + - 不接收或重投 webhook delivery。 +- 若 bridge 在 candidate gate 引入前已经不可用: + - 新 PR 不能通过同一故障 bridge 自动部署自身修复; + - 状态必须明确报告自举死锁; + - 禁止用 branch-follower、轮询、人工 mirror sync、直接 Gitea push、人工 PipelineRun、`trigger-current`、sync/flush 或 delivery 重投伪造自动恢复。 + ## 唯一事实来源 - Gitea source authority、GitHub webhook bridge、公网暴露与 source snapshot 归属 `config/platform-infra/gitea.yaml`。 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 028784d6..25172cb5 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -96,6 +96,14 @@ sourceAuthority: httpPort: 8080 serviceAccountName: gitea-github-sync shutdownGraceMs: 5000 + candidateGate: + enabled: true + configMapName: gitea-github-sync-candidate + jobName: gitea-github-sync-candidate + activeDeadlineSeconds: 45 + ttlSecondsAfterFinished: 300 + healthTimeoutMs: 15000 + pollIntervalMs: 250 inbox: claimName: gitea-github-sync-inbox path: /var/lib/gitea-github-sync diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index e40b80c1..89fe82f0 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -51,4 +51,19 @@ test("owning YAML renders one child Application and the complete durable bridge expect(manifest).toContain("serviceAccountName: gitea-github-sync"); expect(manifest).toContain("name: UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS\n value: \"6500\""); expect(manifest).toContain("name: UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS\n value: \"120000\""); + const candidateConfig = manifest.indexOf("name: gitea-github-sync-candidate\n namespace: devops-infra\n annotations:"); + const candidateJob = manifest.indexOf("kind: Job\nmetadata:\n name: gitea-github-sync-candidate"); + const deployment = manifest.indexOf("kind: Deployment\nmetadata:\n name: gitea-github-sync"); + expect(candidateConfig).toBeGreaterThanOrEqual(0); + expect(candidateJob).toBeGreaterThan(candidateConfig); + expect(deployment).toBeGreaterThan(candidateJob); + expect(manifest).toContain("argocd.argoproj.io/hook: PreSync"); + expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-2"'); + expect(manifest).toContain('argocd.argoproj.io/sync-wave: "-1"'); + expect(manifest).toContain("node /etc/gitea-github-sync-candidate/server.mjs &"); + expect(manifest).toContain("mountPath: /etc/gitea-github-sync-candidate"); + expect(manifest).toContain("name: candidate-inbox\n emptyDir: {}"); + expect(manifest).toContain('gate: "gitea-github-sync-candidate-startup"'); + const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean).map((item) => Bun.YAML.parse(item) as any); + expect(documents.map((item) => item.kind)).toEqual(["ConfigMap", "Job", "ServiceAccount", "ConfigMap", "PersistentVolumeClaim", "Service", "Deployment"]); }); diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 47441cf5..7896e427 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -189,6 +189,15 @@ interface GiteaWebhookSync { httpPort: number; serviceAccountName: string; shutdownGraceMs: number; + candidateGate: { + enabled: boolean; + configMapName: string; + jobName: string; + activeDeadlineSeconds: number; + ttlSecondsAfterFinished: number; + healthTimeoutMs: number; + pollIntervalMs: number; + }; inbox: { claimName: string; path: string; @@ -509,6 +518,7 @@ function parseWebhookSync(record: Record, path: string): GiteaW const ingressRetry = y.objectField(record, "ingressRetry", path); const secret = y.objectField(record, "secret", path); const bridge = y.objectField(record, "bridge", path); + const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`); const inbox = y.objectField(bridge, "inbox", `${path}.bridge`); const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path); const frpc = y.objectField(record, "frpc", path); @@ -542,6 +552,15 @@ function parseWebhookSync(record: Record, path: string): GiteaW httpPort: y.portField(bridge, "httpPort", `${path}.bridge`), serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`), shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`), + candidateGate: { + enabled: y.booleanField(candidateGate, "enabled", `${path}.bridge.candidateGate`), + configMapName: y.kubernetesNameField(candidateGate, "configMapName", `${path}.bridge.candidateGate`), + jobName: y.kubernetesNameField(candidateGate, "jobName", `${path}.bridge.candidateGate`), + activeDeadlineSeconds: positiveInteger(candidateGate, "activeDeadlineSeconds", `${path}.bridge.candidateGate`), + ttlSecondsAfterFinished: positiveInteger(candidateGate, "ttlSecondsAfterFinished", `${path}.bridge.candidateGate`), + healthTimeoutMs: positiveInteger(candidateGate, "healthTimeoutMs", `${path}.bridge.candidateGate`), + pollIntervalMs: positiveInteger(candidateGate, "pollIntervalMs", `${path}.bridge.candidateGate`), + }, inbox: { claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`), path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`), @@ -1278,7 +1297,152 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri const reposJson = JSON.stringify(repositoriesForTarget(gitea, target).map(remoteRepoSpec), null, 2); const serverSource = readFileSync(githubSyncServerFile, "utf8"); const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).digest("hex").slice(0, 16); - return `--- + const gate = sync.bridge.candidateGate; + const candidateManifest = gate.enabled ? `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${gate.configMapName} + namespace: ${target.namespace} + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation + argocd.argoproj.io/sync-wave: "-2" + labels: +${labels} +data: + repos.json: | +${indentBlock(reposJson, 4)} + server.mjs: | +${indentBlock(serverSource, 4)} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: ${gate.jobName} + namespace: ${target.namespace} + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + argocd.argoproj.io/sync-wave: "-1" + labels: +${labels} +spec: + backoffLimit: 0 + activeDeadlineSeconds: ${gate.activeDeadlineSeconds} + ttlSecondsAfterFinished: ${gate.ttlSecondsAfterFinished} + template: + metadata: + labels: + app.kubernetes.io/name: ${sync.bridge.deploymentName} + app.kubernetes.io/component: github-to-gitea-sync-candidate + app.kubernetes.io/part-of: devops-infra + app.kubernetes.io/managed-by: unidesk + spec: + restartPolicy: Never + containers: + - name: candidate-startup-gate + image: ${sync.bridge.image} + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -eu + - -c + - | + node /etc/gitea-github-sync-candidate/server.mjs & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT INT TERM + node --input-type=module - ${gate.healthTimeoutMs} ${gate.pollIntervalMs} <<'NODE' + const timeoutMs = Number.parseInt(process.argv[2], 10); + const pollIntervalMs = Number.parseInt(process.argv[3], 10); + const deadline = Date.now() + timeoutMs; + let lastError = "candidate-not-ready"; + while (Date.now() < deadline) { + try { + const response = await fetch("http://127.0.0.1:${sync.bridge.httpPort}/healthz"); + const body = await response.json(); + if (response.ok && body.ok === true && body.durableInbox?.storageReady === true) { + console.log(JSON.stringify({ ok: true, gate: "gitea-github-sync-candidate-startup", storageReady: true, valuesPrinted: false })); + process.exit(0); + } + lastError = "health-status-" + response.status; + } catch (error) { + lastError = String(error?.cause?.code || error?.message || error); + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + console.error(JSON.stringify({ ok: false, gate: "gitea-github-sync-candidate-startup", errorType: "candidate-health-timeout", error: lastError, valuesPrinted: false })); + process.exit(1); + NODE + env: + - name: UNIDESK_GITEA_WEBHOOK_PORT + value: ${yamlQuote(String(sync.bridge.httpPort))} + - name: UNIDESK_GITEA_WEBHOOK_PATH + value: ${yamlQuote(sync.publicPath)} + - name: UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS + value: ${yamlQuote(String(sync.responseBudgetMs))} + - name: UNIDESK_GITEA_REPOS_PATH + value: /etc/gitea-github-sync-candidate/repos.json + - name: UNIDESK_GITEA_SERVICE_BASE_URL + value: ${yamlQuote(`http://${gitea.app.serviceName}.${target.namespace}.svc.cluster.local:${gitea.app.service.httpPort}`)} + - name: UNIDESK_GITEA_WEBHOOK_RETRY_MAX_ATTEMPTS + value: ${yamlQuote(String(sync.bridge.retry.maxAttempts))} + - name: UNIDESK_GITEA_WEBHOOK_RETRY_INITIAL_DELAY_MS + value: ${yamlQuote(String(sync.bridge.retry.initialDelayMs))} + - name: UNIDESK_GITEA_WEBHOOK_RETRY_MAX_DELAY_MS + value: ${yamlQuote(String(sync.bridge.retry.maxDelayMs))} + - name: UNIDESK_GITEA_WEBHOOK_RETRY_TERMINAL_DELAY_MS + value: ${yamlQuote(String(sync.bridge.retry.terminalRetryDelayMs))} + - name: UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS + value: ${yamlQuote(String(sync.bridge.retry.attemptTimeoutMs))} + - name: UNIDESK_GITEA_WEBHOOK_WORKER_SCAN_INTERVAL_MS + value: ${yamlQuote(String(sync.bridge.retry.scanIntervalMs))} + - name: UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES + value: ${yamlQuote(String(sync.ingressRetry.maxBodyBytes))} + - name: UNIDESK_GITEA_WEBHOOK_INBOX_PATH + value: ${yamlQuote(sync.bridge.inbox.path)} + - name: UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES + value: ${yamlQuote(String(sync.bridge.inbox.maxBytes))} + - name: UNIDESK_GITEA_WEBHOOK_COMMITTED_RETENTION_SECONDS + value: ${yamlQuote(String(sync.bridge.inbox.committedRetentionSeconds))} + - name: UNIDESK_GITEA_WEBHOOK_CLEANUP_INTERVAL_MS + value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))} + - name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS + value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))} + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: ${sync.bridge.secretName} + key: github-token + - name: GITEA_USERNAME + valueFrom: + secretKeyRef: + name: ${sync.bridge.secretName} + key: gitea-username + - name: GITEA_PASSWORD + valueFrom: + secretKeyRef: + name: ${sync.bridge.secretName} + key: gitea-password + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: ${sync.bridge.secretName} + key: github-webhook-secret + volumeMounts: + - name: candidate-config + mountPath: /etc/gitea-github-sync-candidate + readOnly: true + - name: candidate-inbox + mountPath: ${sync.bridge.inbox.path} + volumes: + - name: candidate-config + configMap: + name: ${gate.configMapName} + - name: candidate-inbox + emptyDir: {} +` : ""; + return `${candidateManifest}--- apiVersion: v1 kind: ServiceAccount metadata: @@ -1566,6 +1730,16 @@ function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string) ok: sync.bridge.replicas === 1 && /strategy:\n\s+type: Recreate/u.test(manifest), detail: "The durable inbox has one bridge replica and Deployment strategy Recreate, so journal writes have one owner during rollout.", }, + { + name: "bridge-candidate-presync-gate", + ok: sync.bridge.candidateGate.enabled + && manifest.includes(`name: ${sync.bridge.candidateGate.configMapName}`) + && manifest.includes(`name: ${sync.bridge.candidateGate.jobName}`) + && manifest.includes("argocd.argoproj.io/hook: PreSync") + && manifest.includes("app.kubernetes.io/component: github-to-gitea-sync-candidate") + && manifest.includes("candidate-inbox\n emptyDir: {}"), + detail: "Argo starts the exact ConfigMap-mounted bridge candidate with an isolated inbox before replacing the single-writer Deployment.", + }, { name: "durable-inbox-pvc", ok: manifest.includes(`kind: PersistentVolumeClaim\nmetadata:\n name: ${sync.bridge.inbox.claimName}`)