Merge remote-tracking branch 'origin/master'

This commit is contained in:
Codex
2026-07-11 13:02:10 +02:00
4 changed files with 220 additions and 1 deletions
@@ -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"]);
});
+175 -1
View File
@@ -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}`)