feat: add github to gitea webhook sync

This commit is contained in:
Codex
2026-07-06 01:56:10 +00:00
parent b68c8e3a3e
commit 7efaf3e27b
5 changed files with 925 additions and 30 deletions
+467 -14
View File
@@ -8,17 +8,20 @@ import {
capture,
compactCapture,
createYamlFieldReader,
fingerprintValues,
parseJsonOutput,
readYamlRecord,
shQuote,
} from "./platform-infra-ops-library";
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
import { applyPk01CaddyBlock, escapeTomlString, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service";
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
const configLabel = "config/platform-infra/gitea.yaml";
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh");
const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs");
const fieldManager = "unidesk-platform-infra-gitea";
const y = createYamlFieldReader(configLabel);
@@ -67,6 +70,7 @@ interface GiteaConfig {
url: string;
noProxy: string[];
};
webhookSync: GiteaWebhookSync;
responsibilities: GiteaSourceResponsibility[];
repositories: GiteaMirrorRepository[];
};
@@ -149,6 +153,32 @@ interface GiteaGithubCredential {
requiredFor: string[];
}
interface GiteaWebhookSync {
enabled: boolean;
direction: "github-to-gitea";
publicPath: string;
events: string[];
secret: {
sourceRef: string;
sourceKey: string;
createIfMissing: boolean;
};
bridge: {
deploymentName: string;
serviceName: string;
secretName: string;
configMapName: string;
image: string;
replicas: number;
httpPort: number;
serviceAccountName: string;
};
frpc: {
proxyName: string;
remotePort: number;
};
}
interface GiteaSourceResponsibility {
name: string;
current: string;
@@ -206,6 +236,7 @@ interface MirrorSecrets {
adminUsername: string;
adminPassword: string;
githubToken: string;
webhookSecret: string;
}
interface MirrorRemoteParams {
@@ -275,6 +306,7 @@ function readGiteaConfig(): GiteaConfig {
const sourceAuthority = y.objectField(root, "sourceAuthority", "");
const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority");
const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority");
const webhookSync = y.objectField(sourceAuthority, "webhookSync", "sourceAuthority");
const app = y.objectField(root, "app", "");
const image = y.objectField(app, "image", "app");
const service = y.objectField(app, "service", "app");
@@ -328,6 +360,7 @@ function readGiteaConfig(): GiteaConfig {
url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"),
noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"),
},
webhookSync: parseWebhookSync(webhookSync, "sourceAuthority.webhookSync"),
responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility),
repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository),
},
@@ -417,6 +450,37 @@ function parseGithubCredential(record: Record<string, unknown>, path: string): G
};
}
function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaWebhookSync {
const secret = y.objectField(record, "secret", path);
const bridge = y.objectField(record, "bridge", path);
const frpc = y.objectField(record, "frpc", path);
return {
enabled: y.booleanField(record, "enabled", path),
direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const),
publicPath: y.apiPathField(record, "publicPath", path),
events: y.stringArrayField(record, "events", path),
secret: {
sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`),
sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`),
createIfMissing: y.booleanField(secret, "createIfMissing", `${path}.secret`),
},
bridge: {
deploymentName: y.kubernetesNameField(bridge, "deploymentName", `${path}.bridge`),
serviceName: y.kubernetesNameField(bridge, "serviceName", `${path}.bridge`),
secretName: y.kubernetesNameField(bridge, "secretName", `${path}.bridge`),
configMapName: y.kubernetesNameField(bridge, "configMapName", `${path}.bridge`),
image: y.stringField(bridge, "image", `${path}.bridge`),
replicas: positiveInteger(bridge, "replicas", `${path}.bridge`),
httpPort: y.portField(bridge, "httpPort", `${path}.bridge`),
serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`),
},
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
},
};
}
function parsePublicExposure(record: Record<string, unknown>, path: string): PublicServiceExposure & { secretRoot: string } {
const dns = y.objectField(record, "dns", path);
const frpc = y.objectField(record, "frpc", path);
@@ -527,6 +591,11 @@ function validateConfig(gitea: GiteaConfig): void {
if (!gitea.app.registration.disabled) throw new Error(`${configLabel}.app.registration.disabled must be true for the internal POC service`);
if (gitea.app.probes.healthPath !== gitea.validation.healthPath) throw new Error(`${configLabel}.app.probes.healthPath must match validation.healthPath`);
if (gitea.app.server.rootUrl !== gitea.app.publicExposure.publicBaseUrl.replace(/\/?$/u, "/")) throw new Error(`${configLabel}.app.server.rootUrl must match app.publicExposure.publicBaseUrl for the Web UI`);
if (gitea.sourceAuthority.webhookSync.enabled) {
const events = new Set(gitea.sourceAuthority.webhookSync.events);
if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`);
if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`);
}
const repoKeys = new Set<string>();
for (const repo of gitea.sourceAuthority.repositories) {
if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`);
@@ -571,11 +640,12 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const manifest = renderManifest(gitea, target);
const policy = policyChecks(gitea, target, manifest);
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea);
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret }));
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && gitea.app.publicExposure.enabled
? await applyPk01CaddyBlock(config, "gitea", gitea.app.publicExposure)
? await applyGiteaCaddyBlock(config, gitea)
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
return {
ok: result.exitCode === 0 && parsed?.ok === true && record(caddy).ok !== false,
@@ -586,7 +656,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(gitea, target),
policy,
publicExposure: publicExposureSummary(gitea),
secrets: { frpc: frpcSecretSummary(frpcSecret) },
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
@@ -652,6 +722,9 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Rec
const result = await mirrorStatus(config, options);
return options.full || options.raw ? result : renderMirrorStatus(result);
}
if (action === "webhook") {
return await mirrorWebhookCommand(config, args.slice(1));
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-command",
@@ -662,11 +735,38 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Rec
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target JD01 --confirm",
"bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm",
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target JD01 --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01",
"bun scripts/cli.ts platform-infra gitea mirror webhook test --target JD01 --repo <key> --confirm",
],
},
};
}
async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "status"] = args;
if (action === "apply") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookApply(config, options);
return options.full || options.raw ? result : renderMirrorWebhookApply(result);
}
if (action === "status") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookStatus(config, options);
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
}
if (action === "test") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookTest(config, options);
return options.full || options.raw ? result : renderMirrorWebhookTest(result);
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
usage: "platform-infra gitea mirror webhook apply|status|test --target <node> [--repo <key>] [--confirm]",
};
}
function mirrorPlan(options: CommonOptions): Record<string, unknown> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
@@ -689,7 +789,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
const health = await validate(config, options);
const healthValidation = record(health.validation);
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }));
const parsed = parseJsonOutput(result.stdout);
@@ -724,7 +824,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
const target = resolveTarget(gitea, options.targetId);
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, true);
const secrets = ensureMirrorSecrets(gitea, true, true);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
return {
@@ -744,7 +844,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
const target = resolveTarget(gitea, options.targetId);
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
return {
@@ -759,6 +859,69 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
};
}
async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" };
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-webhook-apply",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
}
async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-webhook-status",
mutation: false,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories,
bridge: record(parsed).bridge,
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
}
async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-test", mutation: false, mode: "missing-confirm", error: "mirror webhook test requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
if (repos.length !== 1) throw new Error("mirror webhook test requires exactly one --repo <key>");
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-test", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-webhook-test",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
@@ -918,7 +1081,139 @@ ${envVars(gitea, target)}
resources:
requests:
storage: ${app.storage.config.size}
${renderFrpcManifest(publicServiceTarget(gitea, target))}`;
${renderFrpcManifest(publicServiceTarget(gitea, target))}
${renderGithubSyncManifest(gitea, target)}`;
}
function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return "";
const labels = ` app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
app.kubernetes.io/part-of: devops-infra
app.kubernetes.io/managed-by: unidesk`;
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 `---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${sync.bridge.configMapName}
namespace: ${target.namespace}
labels:
${labels}
data:
repos.json: |
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
---
apiVersion: v1
kind: Service
metadata:
name: ${sync.bridge.serviceName}
namespace: ${target.namespace}
labels:
${labels}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
ports:
- name: http
port: ${sync.bridge.httpPort}
targetPort: http
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${sync.bridge.deploymentName}
namespace: ${target.namespace}
labels:
${labels}
annotations:
unidesk.ai/sync-direction: ${yamlQuote(sync.direction)}
unidesk.ai/public-path: ${yamlQuote(sync.publicPath)}
spec:
replicas: ${sync.bridge.replicas}
selector:
matchLabels:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
template:
metadata:
labels:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
app.kubernetes.io/part-of: devops-infra
app.kubernetes.io/managed-by: unidesk
annotations:
unidesk.ai/github-sync-config-sha: ${yamlQuote(configHash)}
spec:
containers:
- name: github-to-gitea-sync
image: ${sync.bridge.image}
imagePullPolicy: IfNotPresent
command:
- node
- /etc/gitea-github-sync/server.mjs
ports:
- name: http
containerPort: ${sync.bridge.httpPort}
env:
- name: UNIDESK_GITEA_WEBHOOK_PORT
value: ${yamlQuote(String(sync.bridge.httpPort))}
- name: UNIDESK_GITEA_WEBHOOK_PATH
value: ${yamlQuote(sync.publicPath)}
- name: UNIDESK_GITEA_REPOS_PATH
value: /etc/gitea-github-sync/repos.json
- name: UNIDESK_GITEA_SERVICE_BASE_URL
value: ${yamlQuote(`http://${gitea.app.serviceName}.${target.namespace}.svc.cluster.local:${gitea.app.service.httpPort}`)}
- 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
- name: HTTPS_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.enabled ? gitea.sourceAuthority.githubProxy.url : "")}
- name: HTTP_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.enabled ? gitea.sourceAuthority.githubProxy.url : "")}
- name: NO_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.noProxy.join(","))}
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- name: config
mountPath: /etc/gitea-github-sync
readOnly: true
volumes:
- name: config
configMap:
name: ${sync.bridge.configMapName}
`;
}
function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
@@ -947,7 +1242,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
value: ${yamlQuote(value)}`).join("\n");
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status" | "mirror-webhook-test", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
const env: Record<string, string> = {
UNIDESK_GITEA_ACTION: action,
UNIDESK_GITEA_TARGET_ID: target.id,
@@ -971,6 +1266,15 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: gitea.app.publicExposure.enabled ? "1" : "0",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea),
UNIDESK_GITEA_WEBHOOK_PATH: gitea.sourceAuthority.webhookSync.publicPath,
UNIDESK_GITEA_WEBHOOK_EVENTS: gitea.sourceAuthority.webhookSync.events.join(","),
UNIDESK_GITEA_WEBHOOK_DEPLOYMENT: gitea.sourceAuthority.webhookSync.bridge.deploymentName,
UNIDESK_GITEA_WEBHOOK_SERVICE: gitea.sourceAuthority.webhookSync.bridge.serviceName,
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(gitea.sourceAuthority.webhookSync.bridge.httpPort),
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: gitea.sourceAuthority.webhookSync.bridge.secretName,
};
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
@@ -981,6 +1285,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword;
env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken;
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
@@ -1062,8 +1367,8 @@ function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicSer
};
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig): FrpcSecretMaterial {
return prepareFrpcSecret({
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial {
const base = prepareFrpcSecret({
secretRoot: gitea.app.publicExposure.secretRoot,
exposure: gitea.app.publicExposure,
sourcePathRedactor: redactRepoPath,
@@ -1071,6 +1376,47 @@ function prepareGiteaFrpcSecret(gitea: GiteaConfig): FrpcSecretMaterial {
requiredEnvValue,
readTextFile,
});
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return base;
const extraProxy = [
"[[proxies]]",
`name = "${escapeTomlString(sync.frpc.proxyName)}"`,
'type = "tcp"',
`localIP = "${escapeTomlString(sync.bridge.serviceName)}.${escapeTomlString(target.namespace)}.svc.cluster.local"`,
`localPort = ${sync.bridge.httpPort}`,
`remotePort = ${sync.frpc.remotePort}`,
"",
].join("\n");
const frpcToml = `${base.frpcToml.trim()}\n\n${extraProxy}`;
return {
...base,
frpcToml,
fingerprint: fingerprintValues({ frpcToml }, ["frpcToml"]),
};
}
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
const exposure = gitea.app.publicExposure;
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
handle ${sync.publicPath}* {
reverse_proxy 127.0.0.1:${sync.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
handle {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
}`;
return await applyPk01CaddySiteBlock(config, "gitea", exposure, siteBlock, caddyManagedBlockMarkers("gitea"));
}
function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
@@ -1103,6 +1449,24 @@ function frpcSecretSummary(secret: FrpcSecretMaterial): Record<string, unknown>
};
}
function webhookSecretSummary(gitea: GiteaConfig, secrets: MirrorSecrets): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
return {
enabled: sync.enabled,
publicUrl: githubWebhookPublicUrl(gitea),
sourceRef: sync.secret.sourceRef,
targetSecret: sync.bridge.secretName,
keys: ["github-token", "gitea-username", "gitea-password", "github-webhook-secret"],
fingerprint: fingerprintSecretParts([secrets.webhookSecret]),
valuesPrinted: false,
};
}
function githubWebhookPublicUrl(gitea: GiteaConfig): string {
const base = gitea.app.publicExposure.publicBaseUrl.replace(/\/+$/u, "");
return `${base}${gitea.sourceAuthority.webhookSync.publicPath}`;
}
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
return {
ready: payload.ready === true,
@@ -1183,10 +1547,25 @@ function mirrorNextCommands(targetId: string): Record<string, string> {
bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`,
sync: `bun scripts/cli.ts platform-infra gitea mirror sync --target ${targetId} --confirm`,
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
webhookApply: `bun scripts/cli.ts platform-infra gitea mirror webhook apply --target ${targetId} --confirm`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
};
}
function webhookSyncSummary(gitea: GiteaConfig): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
return {
enabled: sync.enabled,
direction: sync.direction,
publicUrl: githubWebhookPublicUrl(gitea),
events: sync.events,
bridge: sync.bridge,
frpc: sync.frpc,
valuesPrinted: false,
};
}
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
const githubPath = credentialPath(gitea, gitea.sourceAuthority.credentials.github.sourceRef);
@@ -1220,7 +1599,7 @@ function credentialBlockers(credentials: Array<Record<string, unknown>>): string
return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id));
}
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean): MirrorSecrets {
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWebhookSecret: boolean): MirrorSecrets {
const admin = gitea.sourceAuthority.credentials.admin;
const adminPath = credentialPath(gitea, admin.sourceRef);
if (!existsSync(adminPath)) {
@@ -1236,12 +1615,29 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean): MirrorSe
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseEnvFileSafe(githubPath);
const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
const adminUsername = adminLinePair.username;
const adminPassword = adminLinePair.password;
const githubToken = githubEnv[github.sourceKey];
if (!adminUsername || !adminPassword) throw new Error(`${admin.sourceRef} must contain username on line ${admin.usernameLine} and password on line ${admin.passwordLine}`);
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
return { adminUsername, adminPassword, githubToken };
return { adminUsername, adminPassword, githubToken, webhookSecret };
}
function ensureWebhookSecret(gitea: GiteaConfig, createIfMissing: boolean): string {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return "";
const path = credentialPath(gitea, sync.secret.sourceRef);
if (!existsSync(path)) {
if (!createIfMissing || !sync.secret.createIfMissing) throw new Error(`${sync.secret.sourceRef} is missing; run platform-infra gitea apply --target <node> --confirm first`);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${sync.secret.sourceKey}=ghs_${randomBytes(32).toString("base64url")}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
const values = parseEnvFileSafe(path);
const value = values[sync.secret.sourceKey];
if (!value) throw new Error(`${sync.secret.sourceRef} must contain ${sync.secret.sourceKey}`);
return value;
}
function credentialPath(gitea: GiteaConfig, sourceRef: string): string {
@@ -1382,6 +1778,52 @@ function renderMirrorStatus(result: Record<string, unknown>): RenderedCliResult
]);
}
function renderMirrorWebhookApply(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook apply", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK APPLY",
...table(["OK", "MUTATION", "TARGET", "URL"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id), stringValue(record(result.webhook).publicUrl)]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), stringValue(repo.active), compactTail(stringValue(repo.error))]);
const bridge = record(result.bridge);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook status", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
...table(["TARGET", "BRIDGE", "READY", "URL"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), stringValue(record(result.webhook).publicUrl)]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ACTIVE", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookApply)}`,
]);
}
function renderMirrorWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), boolText(repo.pingOk), boolText(repo.testOk), boolText(repo.sourceBundleReady), stringValue(repo.sourceCommit ?? repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook test", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK TEST",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"TEST",
...(repos.length === 0 ? ["-"] : table(["KEY", "GH_PING", "POST_OK", "SNAPSHOT_READY", "SOURCE", "SNAPSHOT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
@@ -1420,8 +1862,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
const remote = record(result.remote);
const steps = record(remote.steps);
const frpcStep = record(steps.frpcSecret);
const webhookSecretStep = record(steps.webhookSyncSecret);
const applyStep = record(steps.apply);
const rolloutStep = record(steps.rollout);
const frpcRolloutStep = record(steps.frpcRollout);
const webhookRolloutStep = record(steps.webhookSyncRollout);
const caddy = record(result.pk01Caddy);
return rendered(result, "platform-infra gitea apply", [
"PLATFORM-INFRA GITEA APPLY",
@@ -1430,8 +1875,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
"STEPS",
...table(["STEP", "EXIT", "DETAIL"], [
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))],
["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))],
["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))],
["frpc-rollout", stringValue(frpcRolloutStep.exitCode), compactLine(stringValue(frpcRolloutStep.stderrTail, stringValue(frpcRolloutStep.stdoutTail)))],
["webhook-rollout", stringValue(webhookRolloutStep.exitCode), compactLine(stringValue(webhookRolloutStep.stderrTail, stringValue(webhookRolloutStep.stdoutTail)))],
["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))],
]),
...remoteErrorLines(result),
@@ -1660,6 +2108,11 @@ function yamlQuote(value: string): string {
return JSON.stringify(value);
}
function indentBlock(value: string, spaces: number): string {
const prefix = " ".repeat(spaces);
return value.split(/\r?\n/u).map((line) => `${prefix}${line}`).join("\n");
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}