|
|
|
@@ -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}`)
|
|
|
|
|