Merge pull request #1777 from pikasTech/fix/1776-gitea-bridge-presync-gate
fix: 为 webhook bridge 自更新增加 PreSync 门禁
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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}`)
|
||||
|
||||
Reference in New Issue
Block a user