feat: add github to gitea webhook sync
This commit is contained in:
@@ -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> : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user