1757 lines
74 KiB
TypeScript
1757 lines
74 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { CliInputError, type RenderedCliResult } from "./output";
|
|
import {
|
|
capture,
|
|
compactCapture,
|
|
fingerprintValues,
|
|
parseJsonOutput,
|
|
shQuote,
|
|
} from "./platform-infra-ops-library";
|
|
import { applyPk01CaddyBlock, escapeTomlString, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
|
|
import type { PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service";
|
|
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
|
|
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle } from "./platform-infra-gitea-caddy";
|
|
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
|
|
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
|
import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority";
|
|
import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments";
|
|
import {
|
|
buildMirrorWebhookStatusDisclosure,
|
|
rawMirrorWebhookStatus,
|
|
} from "./platform-infra-gitea-disclosure";
|
|
import {
|
|
renderApply,
|
|
renderMirrorBootstrap,
|
|
renderMirrorPlan,
|
|
renderMirrorStatus,
|
|
renderMirrorSync,
|
|
renderMirrorWebhookApply,
|
|
renderMirrorWebhookStatus,
|
|
renderPlan,
|
|
renderStatus,
|
|
} from "./platform-infra-gitea-render";
|
|
import {
|
|
GITEA_CONFIG_LABEL,
|
|
readGiteaConfig,
|
|
resolveTarget,
|
|
targetWebhookSync,
|
|
webhookCaddyRoutes,
|
|
type GiteaAdminCredential,
|
|
type GiteaConfig,
|
|
type GiteaGithubCredential,
|
|
type GiteaMirrorRepository,
|
|
type GiteaSourceResponsibility,
|
|
type GiteaTarget,
|
|
type GiteaWebhookGitOpsDelivery,
|
|
type GiteaWebhookSync,
|
|
} from "./platform-infra-gitea-config";
|
|
|
|
const configLabel = GITEA_CONFIG_LABEL;
|
|
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh");
|
|
const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs");
|
|
const githubSyncEntrypointFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-entrypoint.mjs");
|
|
const statusEvaluatorFile = rootPath("scripts", "src", "platform_infra_gitea_status_evaluator.py");
|
|
const fieldManager = "unidesk-platform-infra-gitea";
|
|
interface CommonOptions {
|
|
targetId: string | null;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface ApplyOptions extends CommonOptions {
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
interface MirrorOptions extends CommonOptions {
|
|
confirm: boolean;
|
|
repoKey: string | null;
|
|
}
|
|
|
|
interface MirrorWebhookStatusOptions extends MirrorOptions {
|
|
deliveryId: string | null;
|
|
}
|
|
|
|
interface MirrorSecrets {
|
|
adminUsername: string;
|
|
adminPassword: string;
|
|
githubToken: string;
|
|
webhookSecret: string;
|
|
}
|
|
|
|
interface MirrorRemoteParams {
|
|
repos?: GiteaMirrorRepository[];
|
|
secrets?: MirrorSecrets;
|
|
frpcSecret?: FrpcSecretMaterial;
|
|
}
|
|
|
|
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [action = "plan"] = args;
|
|
if (action === "help" || action === "--help") return giteaHelp(args[1]);
|
|
if (action === "plan") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = plan(options);
|
|
return options.full || options.raw ? result : renderPlan(result);
|
|
}
|
|
if (action === "apply") {
|
|
const options = parseApplyOptions(args.slice(1));
|
|
const result = await apply(config, options);
|
|
return options.full || options.raw ? result : renderApply(result);
|
|
}
|
|
if (action === "status") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = await status(config, options);
|
|
return options.full || options.raw ? result : renderStatus(result);
|
|
}
|
|
if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1)));
|
|
if (action === "mirror") return await mirrorCommand(config, args.slice(1));
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-gitea-command",
|
|
args,
|
|
help: giteaHelp(),
|
|
};
|
|
}
|
|
|
|
export function giteaHelp(scope?: string): Record<string, unknown> {
|
|
if (scope === "platform-bootstrap") {
|
|
return {
|
|
command: "platform-infra gitea help platform-bootstrap",
|
|
configTruth: configLabel,
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
|
|
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
|
|
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
|
|
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
|
|
],
|
|
boundary: "这些命令只用于平台首次引导或独立平台维护,不能补齐 PR merge 后的 source delivery。",
|
|
};
|
|
}
|
|
return {
|
|
command: "platform-infra gitea status|validate|mirror status|mirror webhook status",
|
|
configTruth: configLabel,
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra gitea status --target <NODE> [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra gitea validate --target <NODE> [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra gitea mirror status --target <NODE> [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE> [--repo <key>] [--delivery-id <id>] [--full|--raw]",
|
|
],
|
|
scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"],
|
|
boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。",
|
|
};
|
|
}
|
|
|
|
|
|
function plan(options: CommonOptions): Record<string, unknown> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
const manifest = renderManifest(gitea, target);
|
|
const policy = policyChecks(gitea, target, manifest);
|
|
return {
|
|
ok: policy.every((check) => check.ok),
|
|
action: "platform-infra-gitea-plan",
|
|
mutation: false,
|
|
config: configSummary(gitea, target),
|
|
renderPlan: {
|
|
target: targetSummary(target),
|
|
objects: manifestObjectSummary(manifest),
|
|
},
|
|
policy,
|
|
next: giteaReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
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, target);
|
|
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
|
|
const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets });
|
|
const result = await capture(config, target.route, ["sh"], remote.script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && caddyExposureNeeded(gitea, target)
|
|
? 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,
|
|
action: "platform-infra-gitea-apply",
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
mutation: !options.dryRun,
|
|
target: targetSummary(target),
|
|
config: compactConfigSummary(gitea, target),
|
|
policy,
|
|
publicExposure: publicExposureSummary(gitea),
|
|
payloadTransport: remote.payloadSummary,
|
|
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
|
|
pk01Caddy: caddy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: giteaReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const summary = parsed === null ? null : statusSummary(parsed);
|
|
return {
|
|
ok: result.exitCode === 0 && summary?.ready === true,
|
|
action: "platform-infra-gitea-status",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
config: configSummary(gitea, target),
|
|
summary,
|
|
remote: options.raw ? parsed : options.full ? parsed : summary ?? compactCapture(result, { full: true }),
|
|
next: giteaReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("validate", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }).script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-gitea-validate",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
config: compactConfigSummary(gitea, target),
|
|
validation: parsed ?? null,
|
|
remote: options.raw && parsed !== null ? parsed : compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [action = "plan"] = args;
|
|
if (action === "plan") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = mirrorPlan(options);
|
|
return options.full || options.raw ? result : renderMirrorPlan(result);
|
|
}
|
|
if (action === "bootstrap") {
|
|
const options = parseMirrorOptions(args.slice(1));
|
|
const result = await mirrorBootstrap(config, options);
|
|
return options.full || options.raw ? result : renderMirrorBootstrap(result);
|
|
}
|
|
if (action === "sync" || action === "snapshot") {
|
|
const options = parseMirrorOptions(args.slice(1));
|
|
const result = await mirrorSync(config, options);
|
|
return options.full || options.raw ? result : renderMirrorSync(result);
|
|
}
|
|
if (action === "status") {
|
|
const options = parseMirrorOptions(args.slice(1));
|
|
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",
|
|
args,
|
|
help: {
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--repo <key>] [--delivery-id <id>] [--full|--raw]",
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
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 = parseMirrorWebhookStatusOptions(args.slice(1));
|
|
const result = await mirrorWebhookStatus(config, options);
|
|
if (options.raw) return rawMirrorWebhookStatus(result);
|
|
const disclosure = buildMirrorWebhookStatusDisclosure(result, options);
|
|
return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure);
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
|
|
usage: "platform-infra gitea mirror webhook status --target <node> [--repo <key>] [--delivery-id <id>] [--full|--raw]",
|
|
};
|
|
}
|
|
|
|
function mirrorPlan(options: CommonOptions): Record<string, unknown> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-gitea-mirror-plan",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
sourceAuthority: sourceAuthoritySummary(gitea, target),
|
|
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
|
|
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
|
|
credentials: credentialSummaries(gitea),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise<Record<string, unknown>> {
|
|
const gitea = readGiteaConfig();
|
|
const target = resolveTarget(gitea, options.targetId);
|
|
const health = await validate(config, options);
|
|
const healthValidation = record(health.validation);
|
|
const credentials = credentialSummaries(gitea);
|
|
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 }).script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const repositories = arrayRecords(record(parsed).repositories);
|
|
return {
|
|
ok: health.ok === true && result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-gitea-mirror-status",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
serviceHealth: {
|
|
ok: health.ok === true,
|
|
healthStatus: record(healthValidation.health).status,
|
|
endpointsReady: healthValidation.endpointsReady === true,
|
|
serviceProxyPath: healthValidation.serviceProxyPath,
|
|
},
|
|
sourceAuthority: {
|
|
...sourceAuthoritySummary(gitea, target),
|
|
mirrorReady: repositories.length > 0 && repositories.every((repo) => repo.sourceBundleReady === true),
|
|
stageReady: health.ok === true,
|
|
readinessDetail: "Gitea source-authority is ready when every configured repo has branch and snapshot refs in Gitea.",
|
|
},
|
|
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
|
|
repositories: repositories.length > 0 ? repositories : selectedRepos.map((repo) => repositorySummary(gitea, repo)),
|
|
credentials,
|
|
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function mirrorBootstrap(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-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" };
|
|
const repos = selectedRepositories(gitea, target, options.repoKey);
|
|
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 }).script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-gitea-mirror-bootstrap",
|
|
mutation: true,
|
|
target: targetSummary(target),
|
|
repositories: arrayRecords(record(parsed).repositories),
|
|
credentials: credentialSummaries(gitea),
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function mirrorSync(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-sync",
|
|
mutation: false,
|
|
mode: "automatic-source-authority-manual-sync-disabled",
|
|
target: targetSummary(target),
|
|
error: "manual mirror sync is disabled while GitHub webhook durable source authority is enabled; the durable controller owns automatic convergence",
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
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, false);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-gitea-mirror-sync",
|
|
mutation: true,
|
|
target: targetSummary(target),
|
|
repositories: arrayRecords(record(parsed).repositories),
|
|
sourceBundles: arrayRecords(record(parsed).sourceBundles),
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
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 }).script);
|
|
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, target),
|
|
repositories: arrayRecords(record(parsed).repositories),
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhookStatusOptions): 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 }).script);
|
|
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, target),
|
|
repositories,
|
|
bridge: record(parsed).bridge,
|
|
bridgeLogs: record(parsed).bridgeLogs,
|
|
mirrorStatus: record(parsed).mirrorStatus,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: mirrorReadOnlyNextCommands(target.id),
|
|
};
|
|
}
|
|
|
|
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
|
|
const app = gitea.app;
|
|
const image = `${app.image.repository}:${app.image.tag}`;
|
|
const labels = ` app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
app.kubernetes.io/part-of: devops-infra
|
|
app.kubernetes.io/managed-by: unidesk`;
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: devops-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-all
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
${labels}
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress:
|
|
- {}
|
|
egress:
|
|
- {}
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${app.serviceName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
${labels}
|
|
annotations:
|
|
unidesk.ai/spec: ${yamlQuote(gitea.metadata.spec)}
|
|
unidesk.ai/parent-config-ref: ${yamlQuote(gitea.migration.parentConfigRef)}
|
|
spec:
|
|
type: ${app.service.type}
|
|
selector:
|
|
app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
ports:
|
|
- name: http
|
|
port: ${app.service.httpPort}
|
|
targetPort: http
|
|
protocol: TCP
|
|
- name: ssh
|
|
port: ${app.service.sshPort}
|
|
targetPort: ssh
|
|
protocol: TCP
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: ${app.statefulSetName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
${labels}
|
|
annotations:
|
|
unidesk.ai/spec: ${yamlQuote(gitea.metadata.spec)}
|
|
unidesk.ai/env-reuse-policy: ${yamlQuote(gitea.migration.envReusePolicy)}
|
|
spec:
|
|
serviceName: ${app.serviceName}
|
|
replicas: ${app.replicas}
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
app.kubernetes.io/part-of: devops-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
annotations:
|
|
unidesk.ai/runtime-plane: ${yamlQuote(gitea.migration.runtimePlane)}
|
|
unidesk.ai/build-plane: ${yamlQuote(gitea.migration.buildPlane)}
|
|
spec:
|
|
securityContext:
|
|
runAsUser: ${app.securityContext.runAsUser}
|
|
runAsGroup: ${app.securityContext.runAsGroup}
|
|
fsGroup: ${app.securityContext.fsGroup}
|
|
fsGroupChangePolicy: OnRootMismatch
|
|
containers:
|
|
- name: gitea
|
|
image: ${image}
|
|
imagePullPolicy: ${app.image.pullPolicy}
|
|
ports:
|
|
- name: http
|
|
containerPort: ${app.service.httpPort}
|
|
- name: ssh
|
|
containerPort: ${app.service.sshPort}
|
|
env:
|
|
${envVars(gitea, target)}
|
|
readinessProbe:
|
|
httpGet:
|
|
path: ${app.probes.healthPath}
|
|
port: http
|
|
initialDelaySeconds: ${app.probes.initialDelaySeconds}
|
|
periodSeconds: ${app.probes.periodSeconds}
|
|
timeoutSeconds: ${app.probes.timeoutSeconds}
|
|
failureThreshold: ${app.probes.failureThreshold}
|
|
livenessProbe:
|
|
httpGet:
|
|
path: ${app.probes.healthPath}
|
|
port: http
|
|
initialDelaySeconds: ${app.probes.initialDelaySeconds}
|
|
periodSeconds: ${app.probes.periodSeconds}
|
|
timeoutSeconds: ${app.probes.timeoutSeconds}
|
|
failureThreshold: ${app.probes.failureThreshold}
|
|
resources:
|
|
requests:
|
|
cpu: ${yamlQuote(app.resources.requests.cpu)}
|
|
memory: ${yamlQuote(app.resources.requests.memory)}
|
|
limits:
|
|
cpu: ${yamlQuote(app.resources.limits.cpu)}
|
|
memory: ${yamlQuote(app.resources.limits.memory)}
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: ${app.storage.data.mountPath}
|
|
- name: config
|
|
mountPath: ${app.storage.config.mountPath}
|
|
volumeClaimTemplates:
|
|
- metadata:
|
|
name: data
|
|
labels:
|
|
app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
app.kubernetes.io/part-of: devops-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: ${target.storageClassName}
|
|
resources:
|
|
requests:
|
|
storage: ${app.storage.data.size}
|
|
- metadata:
|
|
name: config
|
|
labels:
|
|
app.kubernetes.io/name: ${app.name}
|
|
app.kubernetes.io/component: gitea
|
|
app.kubernetes.io/part-of: devops-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: ${target.storageClassName}
|
|
resources:
|
|
requests:
|
|
storage: ${app.storage.config.size}
|
|
${renderFrpcManifest(publicServiceTarget(gitea, target))}
|
|
${renderGithubSyncManifest(gitea, target)}`;
|
|
}
|
|
|
|
function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): string {
|
|
const sync = targetWebhookSync(gitea, target);
|
|
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 entrypointSource = readFileSync(githubSyncEntrypointFile, "utf8");
|
|
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).update("\0").update(entrypointSource).digest("hex").slice(0, 16);
|
|
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)}
|
|
entrypoint.mjs: |
|
|
${indentBlock(entrypointSource, 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/entrypoint.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: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
|
|
- 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:
|
|
name: ${sync.bridge.serviceAccountName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
${labels}
|
|
---
|
|
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)}
|
|
entrypoint.mjs: |
|
|
${indentBlock(entrypointSource, 4)}
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: ${sync.bridge.inbox.claimName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
${labels}
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: ${target.storageClassName}
|
|
resources:
|
|
requests:
|
|
storage: ${sync.bridge.inbox.storageSize}
|
|
---
|
|
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}
|
|
strategy:
|
|
type: Recreate
|
|
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:
|
|
serviceAccountName: ${sync.bridge.serviceAccountName}
|
|
terminationGracePeriodSeconds: ${Math.ceil(sync.bridge.shutdownGraceMs / 1000) + 5}
|
|
containers:
|
|
- name: github-to-gitea-sync
|
|
image: ${sync.bridge.image}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- node
|
|
- /etc/gitea-github-sync/entrypoint.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_WEBHOOK_RESPONSE_BUDGET_MS
|
|
value: ${yamlQuote(String(sync.responseBudgetMs))}
|
|
- 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: 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: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
|
|
- 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
|
|
- name: durable-inbox
|
|
mountPath: ${sync.bridge.inbox.path}
|
|
volumes:
|
|
- name: config
|
|
configMap:
|
|
name: ${sync.bridge.configMapName}
|
|
- name: durable-inbox
|
|
persistentVolumeClaim:
|
|
claimName: ${sync.bridge.inbox.claimName}
|
|
`;
|
|
}
|
|
|
|
export function renderGiteaWebhookSyncDesiredManifest(targetId: string): string {
|
|
const gitea = readGiteaConfig();
|
|
const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery;
|
|
const target = resolveTarget(gitea, targetId);
|
|
if (!delivery.enabled) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.enabled must be true`);
|
|
if (target.id !== delivery.targetId) throw new Error(`GitOps delivery target ${delivery.targetId} does not match requested target ${target.id}`);
|
|
return [
|
|
renderGithubSyncManifest(gitea, target).trim(),
|
|
renderPlatformInfraGiteaDesiredFragments(target.id).trim(),
|
|
].filter((item) => item.length > 0).join("\n---\n") + "\n";
|
|
}
|
|
|
|
export function readGiteaWebhookGitOpsDelivery(): GiteaWebhookGitOpsDelivery {
|
|
return readGiteaConfig().sourceAuthority.webhookSync.gitOpsDelivery;
|
|
}
|
|
|
|
function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
|
|
const app = gitea.app;
|
|
const values: Record<string, string> = {
|
|
GITEA_WORK_DIR: app.storage.data.mountPath,
|
|
GITEA__security__INSTALL_LOCK: "true",
|
|
GITEA__server__PROTOCOL: app.server.protocol,
|
|
GITEA__server__DOMAIN: app.server.domain,
|
|
GITEA__server__ROOT_URL: app.server.rootUrl,
|
|
GITEA__server__HTTP_ADDR: "0.0.0.0",
|
|
GITEA__server__HTTP_PORT: String(app.service.httpPort),
|
|
GITEA__server__SSH_DOMAIN: app.server.sshDomain,
|
|
GITEA__server__SSH_PORT: String(app.service.sshPort),
|
|
GITEA__server__START_SSH_SERVER: app.server.startSshServer ? "true" : "false",
|
|
GITEA__database__DB_TYPE: app.database.type,
|
|
GITEA__database__PATH: app.database.path,
|
|
GITEA__repository__ROOT: `${app.storage.data.mountPath}/git/repositories`,
|
|
GITEA__actions__ENABLED: app.actions.enabled ? "true" : "false",
|
|
GITEA__webhook__ALLOWED_HOST_LIST: app.webhook.allowedHostList,
|
|
GITEA__service__DISABLE_REGISTRATION: app.registration.disabled ? "true" : "false",
|
|
GITEA__log__LEVEL: "Info",
|
|
UNIDESK_GITEA_TARGET: target.id,
|
|
};
|
|
return Object.entries(values).map(([name, value]) => ` - name: ${name}
|
|
value: ${yamlQuote(value)}`).join("\n");
|
|
}
|
|
|
|
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
|
|
const frpcExposure = targetFrpcExposure(gitea, target);
|
|
const sync = targetWebhookSync(gitea, target);
|
|
const env: Record<string, string> = {
|
|
UNIDESK_GITEA_ACTION: action,
|
|
UNIDESK_GITEA_TARGET_ID: target.id,
|
|
UNIDESK_GITEA_ROUTE: target.route,
|
|
UNIDESK_GITEA_NAMESPACE: target.namespace,
|
|
UNIDESK_GITEA_APP_NAME: gitea.app.name,
|
|
UNIDESK_GITEA_STATEFULSET_NAME: gitea.app.statefulSetName,
|
|
UNIDESK_GITEA_SERVICE_NAME: gitea.app.serviceName,
|
|
UNIDESK_GITEA_HTTP_PORT: String(gitea.app.service.httpPort),
|
|
UNIDESK_GITEA_HEALTH_PATH: gitea.validation.healthPath,
|
|
UNIDESK_GITEA_IMAGE: `${gitea.app.image.repository}:${gitea.app.image.tag}`,
|
|
UNIDESK_GITEA_FIELD_MANAGER: fieldManager,
|
|
UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS: String(gitea.validation.waitTimeoutSeconds),
|
|
UNIDESK_GITEA_DRY_RUN: options.dryRun ? "1" : "0",
|
|
UNIDESK_GITEA_WAIT: options.wait ? "1" : "0",
|
|
UNIDESK_GITEA_FULL: options.full ? "1" : "0",
|
|
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
|
|
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
|
|
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
|
|
UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key,
|
|
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
|
|
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
|
|
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
|
|
UNIDESK_GITEA_FRPC_SECRET_NAME: frpcExposure?.frpc.secretName ?? gitea.app.publicExposure.frpc.secretName,
|
|
UNIDESK_GITEA_FRPC_SECRET_KEY: frpcExposure?.frpc.secretKey ?? gitea.app.publicExposure.frpc.secretKey,
|
|
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
|
|
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea, target),
|
|
UNIDESK_GITEA_WEBHOOK_PATH: sync.publicPath,
|
|
UNIDESK_GITEA_WEBHOOK_EVENTS: sync.events.join(","),
|
|
UNIDESK_GITEA_WEBHOOK_DEPLOYMENT: sync.bridge.deploymentName,
|
|
UNIDESK_GITEA_WEBHOOK_SERVICE: sync.bridge.serviceName,
|
|
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(sync.bridge.httpPort),
|
|
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: sync.bridge.secretName,
|
|
};
|
|
if (params.frpcSecret !== undefined) {
|
|
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
|
|
env.UNIDESK_GITEA_FRPC_SECRET_KEY = params.frpcSecret.secretKey;
|
|
}
|
|
if (params.secrets !== undefined) {
|
|
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");
|
|
const payloads = {
|
|
manifest,
|
|
repositories: JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))),
|
|
statusEvaluator: readFileSync(statusEvaluatorFile, "utf8"),
|
|
frpcToml: params.frpcSecret?.frpcToml ?? "",
|
|
};
|
|
return {
|
|
script: `${exports}\n${renderGiteaRemotePayloadMaterializer(payloads)}\n${readFileSync(remoteScriptFile, "utf8")}`,
|
|
payloadSummary: summarizeGiteaRemotePayloads(payloads),
|
|
};
|
|
}
|
|
|
|
function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string): Array<Record<string, unknown>> {
|
|
const sync = targetWebhookSync(gitea, target);
|
|
const delivery = sync.gitOpsDelivery;
|
|
return [
|
|
{ name: "yaml-source-of-truth", ok: true, detail: "Gitea target, namespace, image, storage, ports and probes are read from config/platform-infra/gitea.yaml." },
|
|
{ name: "gitea-pac-service-contract", ok: target.namespace === "devops-infra" && gitea.app.serviceName === "gitea-http", detail: "The service matches the active Gitea source authority consumed by config/platform-infra/pipelines-as-code.yaml." },
|
|
{ name: "cluster-internal-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(manifest) && !/^\s*kind:\s*Ingress\s*$/mu.test(manifest), detail: "Gitea is ClusterIP-only for the POC." },
|
|
{ name: "runtime-zero-docker", ok: !manifest.includes("/var/run/docker.sock") && !/^\s*hostPath:\s*$/mu.test(manifest), detail: "Runtime Gitea does not mount Docker socket or hostPath." },
|
|
{ name: "rootless-image", ok: /-rootless$/u.test(gitea.app.image.tag), detail: "The runtime image is Gitea rootless." },
|
|
{ name: "actions-not-trigger-authority", ok: gitea.app.actions.enabled, detail: "Gitea may expose its built-in Actions capability, but JD01 CI/CD trigger authority is Pipelines-as-Code, not act_runner." },
|
|
{ name: "env-reuse-preserved", ok: gitea.migration.envReusePolicy === "preserve-existing-runtime-env-reuse", detail: "This install does not replace the existing env reuse path or runtime deployment." },
|
|
{
|
|
name: "durable-inbox-single-writer",
|
|
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}`)
|
|
&& manifest.includes(`claimName: ${sync.bridge.inbox.claimName}`)
|
|
&& manifest.includes(`mountPath: ${sync.bridge.inbox.path}`),
|
|
detail: "The webhook bridge fsync inbox path, PVC claim, capacity and retention are rendered from the owning Gitea YAML.",
|
|
},
|
|
{
|
|
name: "durable-bridge-service-account",
|
|
ok: manifest.includes(`kind: ServiceAccount\nmetadata:\n name: ${sync.bridge.serviceAccountName}`)
|
|
&& manifest.includes(`serviceAccountName: ${sync.bridge.serviceAccountName}`),
|
|
detail: "The bridge ServiceAccount and Deployment binding are rendered together; the bootstrap PaC task continues to use the pre-existing default ServiceAccount.",
|
|
},
|
|
{
|
|
name: "independent-gitops-self-delivery",
|
|
ok: delivery.targetId !== target.id || (delivery.enabled
|
|
&& existsSync(rootPath(".tekton", "platform-infra-gitea-nc01-pac.yaml"))
|
|
&& delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")
|
|
&& !delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")),
|
|
detail: "An independent PaC task publishes a child Argo Application into the existing unidesk-host bootstrap path and keeps bridge desired resources on an independent path.",
|
|
},
|
|
];
|
|
}
|
|
|
|
function configSummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
|
|
return {
|
|
path: configLabel,
|
|
metadata: gitea.metadata,
|
|
migration: gitea.migration,
|
|
target: targetSummary(target),
|
|
app: appSummary(gitea),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function compactConfigSummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
|
|
return {
|
|
path: configLabel,
|
|
target: targetSummary(target),
|
|
app: {
|
|
image: `${gitea.app.image.repository}:${gitea.app.image.tag}`,
|
|
serviceDns: serviceDns(gitea, target),
|
|
rootUrl: gitea.app.server.rootUrl,
|
|
publicBaseUrl: gitea.app.publicExposure.publicBaseUrl,
|
|
actionsEnabled: gitea.app.actions.enabled,
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function targetSummary(target: GiteaTarget): Record<string, unknown> {
|
|
return {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
role: target.role,
|
|
createNamespace: target.createNamespace,
|
|
storageClassName: target.storageClassName,
|
|
publicExposureEnabled: target.publicExposureEnabled,
|
|
webhookSync: target.webhookSync,
|
|
};
|
|
}
|
|
|
|
function appSummary(gitea: GiteaConfig): Record<string, unknown> {
|
|
return {
|
|
name: gitea.app.name,
|
|
statefulSetName: gitea.app.statefulSetName,
|
|
serviceName: gitea.app.serviceName,
|
|
image: `${gitea.app.image.repository}:${gitea.app.image.tag}`,
|
|
replicas: gitea.app.replicas,
|
|
service: gitea.app.service,
|
|
rootUrl: gitea.app.server.rootUrl,
|
|
publicExposure: publicExposureSummary(gitea),
|
|
actionsEnabled: gitea.app.actions.enabled,
|
|
registrationDisabled: gitea.app.registration.disabled,
|
|
storage: gitea.app.storage,
|
|
healthPath: gitea.validation.healthPath,
|
|
};
|
|
}
|
|
|
|
function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicServiceTarget {
|
|
return {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
replicas: 1,
|
|
publicExposure: targetPublicExposure(gitea, target),
|
|
};
|
|
}
|
|
|
|
function publicExposureEnabled(gitea: GiteaConfig, target: GiteaTarget): boolean {
|
|
return gitea.app.publicExposure.enabled && target.publicExposureEnabled;
|
|
}
|
|
|
|
function targetPublicExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] {
|
|
const frpcExposure = targetFrpcExposure(gitea, target);
|
|
return frpcExposure === null ? { ...gitea.app.publicExposure, enabled: false } : frpcExposure;
|
|
}
|
|
|
|
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial | undefined {
|
|
const exposure = targetFrpcExposure(gitea, target);
|
|
if (exposure === null) return undefined;
|
|
const base = prepareFrpcSecret({
|
|
secretRoot: gitea.app.publicExposure.secretRoot,
|
|
exposure,
|
|
sourcePathRedactor: redactRepoPath,
|
|
parseEnvFile,
|
|
requiredEnvValue,
|
|
readTextFile,
|
|
});
|
|
const sync = targetWebhookSync(gitea, target);
|
|
if (!sync.enabled || !publicExposureEnabled(gitea, target)) 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"]),
|
|
};
|
|
}
|
|
|
|
function targetFrpcExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] | null {
|
|
if (publicExposureEnabled(gitea, target)) return gitea.app.publicExposure;
|
|
if (target.webhookSync === null || !gitea.sourceAuthority.webhookSync.enabled) return null;
|
|
const sync = targetWebhookSync(gitea, target);
|
|
return {
|
|
...gitea.app.publicExposure,
|
|
enabled: true,
|
|
frpc: {
|
|
...gitea.app.publicExposure.frpc,
|
|
proxyName: sync.frpc.proxyName,
|
|
remotePort: sync.frpc.remotePort,
|
|
localIP: `${sync.bridge.serviceName}.${target.namespace}.svc.cluster.local`,
|
|
localPort: sync.bridge.httpPort,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
function caddyExposureNeeded(gitea: GiteaConfig, target: GiteaTarget): boolean {
|
|
return publicExposureEnabled(gitea, target) || (gitea.sourceAuthority.webhookSync.enabled && target.webhookSync !== null);
|
|
}
|
|
|
|
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
|
|
const exposure = gitea.app.publicExposure;
|
|
const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
|
|
const webhookHandles = webhookCaddyRoutes(gitea)
|
|
.sort((left, right) => right.publicPath.length - left.publicPath.length)
|
|
.map((route) => renderGiteaWebhookCaddyHandle(
|
|
route,
|
|
ingressRetry,
|
|
gitea.sourceAuthority.webhookSync.responseBudgetMs,
|
|
exposure.pk01.responseHeaderTimeoutSeconds,
|
|
))
|
|
.join("\n\n");
|
|
if (webhookHandles === "") return await applyPk01CaddyBlock(config, "gitea", exposure);
|
|
const siteBlock = `${exposure.dns.hostname} {
|
|
${webhookHandles}
|
|
|
|
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> {
|
|
const exposure = gitea.app.publicExposure;
|
|
return {
|
|
enabled: exposure.enabled,
|
|
publicBaseUrl: exposure.publicBaseUrl,
|
|
internalReadUrlPolicy: "k3s-consumers-use-gitea-http-svc-not-public-url",
|
|
dns: exposure.dns,
|
|
frpc: {
|
|
deploymentName: exposure.frpc.deploymentName,
|
|
remotePort: exposure.frpc.remotePort,
|
|
localIP: exposure.frpc.localIP,
|
|
localPort: exposure.frpc.localPort,
|
|
tokenSourceRef: exposure.frpc.tokenSourceRef,
|
|
valuesPrinted: false,
|
|
},
|
|
pk01: exposure.pk01,
|
|
};
|
|
}
|
|
|
|
function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record<string, unknown> {
|
|
if (secret === undefined) return { enabled: false, skipped: true, reason: "target-public-exposure-disabled", valuesPrinted: false };
|
|
return {
|
|
sourceRef: secret.sourceRef,
|
|
sourcePath: secret.sourcePath,
|
|
secretName: secret.secretName,
|
|
secretKey: secret.secretKey,
|
|
fingerprint: secret.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function webhookSecretSummary(gitea: GiteaConfig, target: GiteaTarget, secrets: MirrorSecrets): Record<string, unknown> {
|
|
const sync = targetWebhookSync(gitea, target);
|
|
return {
|
|
enabled: sync.enabled,
|
|
publicUrl: githubWebhookPublicUrl(gitea, target),
|
|
sourceRef: sync.secret.sourceRef,
|
|
targetSecret: sync.bridge.secretName,
|
|
keys: [gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, "gitea-username", "gitea-password", "github-webhook-secret"],
|
|
fingerprint: fingerprintSecretParts([secrets.webhookSecret]),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function githubWebhookPublicUrl(gitea: GiteaConfig, target: GiteaTarget): string {
|
|
const base = gitea.app.publicExposure.publicBaseUrl.replace(/\/+$/u, "");
|
|
return `${base}${targetWebhookSync(gitea, target).publicPath}`;
|
|
}
|
|
|
|
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
ready: payload.ready === true,
|
|
target: payload.target,
|
|
route: payload.route,
|
|
namespace: payload.namespace,
|
|
image: payload.image,
|
|
serviceDns: payload.serviceDns,
|
|
networkPolicy: payload.networkPolicy,
|
|
statefulSet: payload.statefulSet,
|
|
service: payload.service,
|
|
endpointsReady: payload.endpointsReady === true,
|
|
pods: Array.isArray(payload.pods) ? payload.pods : [],
|
|
pvcs: Array.isArray(payload.pvcs) ? payload.pvcs : [],
|
|
eventsTail: Array.isArray(payload.eventsTail) ? payload.eventsTail : [],
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function sourceAuthoritySummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
|
|
return {
|
|
enabled: gitea.sourceAuthority.enabled,
|
|
stage: gitea.sourceAuthority.stage,
|
|
statusAuthority: gitea.sourceAuthority.statusAuthority,
|
|
firstCiConsumer: gitea.sourceAuthority.firstCiConsumer,
|
|
target: target.id,
|
|
serviceBaseUrl: gitea.app.server.rootUrl,
|
|
repositoryCount: repositoriesForTarget(gitea, target).length,
|
|
};
|
|
}
|
|
|
|
function responsibilitySummary(item: GiteaSourceResponsibility): Record<string, unknown> {
|
|
return { ...item };
|
|
}
|
|
|
|
function repositoriesForTarget(gitea: GiteaConfig, target: GiteaTarget): GiteaMirrorRepository[] {
|
|
return gitea.sourceAuthority.repositories.filter((repo) => repo.targetId.toLowerCase() === target.id.toLowerCase());
|
|
}
|
|
|
|
function selectedRepositories(gitea: GiteaConfig, target: GiteaTarget, repoKey: string | null): GiteaMirrorRepository[] {
|
|
const repos = repositoriesForTarget(gitea, target);
|
|
if (repoKey === null) return repos;
|
|
const repo = repos.find((item) => item.key === repoKey);
|
|
if (repo === undefined) throw new CliInputError(`Unknown Gitea mirror repository key: ${repoKey}`, {
|
|
code: "unknown-repository-key",
|
|
argument: repoKey,
|
|
supported: repos.map((item) => item.key),
|
|
usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node> --repo <key>",
|
|
});
|
|
return [repo];
|
|
}
|
|
|
|
function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Record<string, unknown> {
|
|
return {
|
|
key: repo.key,
|
|
upstreamRepository: repo.upstream.repository,
|
|
upstreamBranch: repo.upstream.branch,
|
|
giteaOwner: repo.gitea.owner,
|
|
giteaRepo: repo.gitea.name,
|
|
mirrorMode: repo.gitea.mirrorMode,
|
|
readUrl: repo.gitea.readUrl,
|
|
snapshotPrefix: repo.snapshot.prefix,
|
|
legacyReadUrl: repo.legacyGitMirror.readUrl,
|
|
legacyDisposition: repo.legacyGitMirror.disposition,
|
|
sourceAuthority: "gitea",
|
|
rootUrlMatches: repo.gitea.readUrl.startsWith(gitea.app.server.rootUrl),
|
|
};
|
|
}
|
|
|
|
function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
|
|
return {
|
|
key: repo.key,
|
|
upstream: repo.upstream,
|
|
gitea: repo.gitea,
|
|
gitops: repo.gitops,
|
|
snapshot: repo.snapshot,
|
|
legacyGitMirror: repo.legacyGitMirror,
|
|
};
|
|
}
|
|
|
|
function mirrorReadOnlyNextCommands(targetId: string): Record<string, string> {
|
|
return {
|
|
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
|
|
statusFull: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
|
|
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
|
|
webhookStatusFull: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --full`,
|
|
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
|
|
};
|
|
}
|
|
|
|
function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
|
|
const sync = targetWebhookSync(gitea, target);
|
|
const ingressAttemptBudgetMs = sync.ingressRetry.enabled
|
|
? deriveGiteaWebhookAttemptBudgetMs(sync.responseBudgetMs, sync.ingressRetry)
|
|
: null;
|
|
const ingressResponseHeaderTimeoutMs = sync.ingressRetry.enabled
|
|
? deriveGiteaWebhookResponseHeaderTimeoutMs(sync.responseBudgetMs, sync.ingressRetry)
|
|
: null;
|
|
return {
|
|
enabled: sync.enabled,
|
|
direction: sync.direction,
|
|
publicUrl: githubWebhookPublicUrl(gitea, target),
|
|
events: sync.events,
|
|
responseBudgetMs: sync.responseBudgetMs,
|
|
ingressAttemptBudgetMs,
|
|
ingressResponseHeaderTimeoutMs,
|
|
ingressRetry: {
|
|
...sync.ingressRetry,
|
|
method: "POST",
|
|
bodyReplay: "request-body-size-limited-and-fully-buffered",
|
|
},
|
|
bridge: sync.bridge,
|
|
gitOpsDelivery: sync.gitOpsDelivery,
|
|
frpc: sync.frpc,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
|
|
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
|
|
const github = gitea.sourceAuthority.credentials.github;
|
|
const githubPath = credentialPath(gitea, github.sourceRef);
|
|
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
|
|
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
|
|
return [
|
|
{
|
|
id: "gitea-admin",
|
|
format: gitea.sourceAuthority.credentials.admin.format,
|
|
sourceRef: gitea.sourceAuthority.credentials.admin.sourceRef,
|
|
sourcePath: adminPath,
|
|
present: existsSync(adminPath),
|
|
requiredKeysPresent: Boolean(adminLinePair.username) && Boolean(adminLinePair.password),
|
|
fingerprint: fingerprintSecretParts([adminLinePair.username, adminLinePair.password]),
|
|
valuesPrinted: false,
|
|
},
|
|
{
|
|
id: "github-upstream",
|
|
transport: github.transport,
|
|
sourceRef: github.sourceRef,
|
|
sourcePath: githubPath,
|
|
present: existsSync(githubPath),
|
|
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
|
|
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
|
|
valuesPrinted: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
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, createWebhookSecret: boolean): MirrorSecrets {
|
|
const admin = gitea.sourceAuthority.credentials.admin;
|
|
const adminPath = credentialPath(gitea, admin.sourceRef);
|
|
if (!existsSync(adminPath)) {
|
|
if (!createAdmin) throw new Error(`${admin.sourceRef} is missing; run mirror bootstrap --confirm first`);
|
|
mkdirSync(dirname(adminPath), { recursive: true });
|
|
const username = "unidesk-admin";
|
|
const password = `udg_${randomBytes(32).toString("base64url")}`;
|
|
writeFileSync(adminPath, `${username}\n${password}\n`, { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(adminPath, 0o600);
|
|
}
|
|
const adminLinePair = parseLinePairCredential(adminPath, admin);
|
|
const github = gitea.sourceAuthority.credentials.github;
|
|
const githubPath = credentialPath(gitea, github.sourceRef);
|
|
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
|
|
const githubEnv = parseGithubCredentialFile(githubPath, github);
|
|
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, 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 {
|
|
return sourceRef.startsWith("/") ? sourceRef : join(gitea.sourceAuthority.credentials.sourceRoot, sourceRef);
|
|
}
|
|
|
|
function parseLinePairCredential(path: string, admin: GiteaAdminCredential): { username: string | null; password: string | null } {
|
|
const lines = readFileSync(path, "utf8").split(/\r?\n/u);
|
|
return {
|
|
username: lines[admin.usernameLine - 1]?.trim() || null,
|
|
password: lines[admin.passwordLine - 1]?.trim() || null,
|
|
};
|
|
}
|
|
|
|
function parseEnvFileSafe(path: string): Record<string, string> {
|
|
const values: Record<string, string> = {};
|
|
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/u)) {
|
|
let line = rawLine.trim();
|
|
if (line.length === 0 || line.startsWith("#")) continue;
|
|
if (line.startsWith("export ")) line = line.slice("export ".length).trim();
|
|
const eq = line.indexOf("=");
|
|
if (eq <= 0) continue;
|
|
const key = line.slice(0, eq).trim();
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
values[key] = unquote(line.slice(eq + 1).trim());
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function parseGithubCredentialFile(path: string, github: GiteaGithubCredential): Record<string, string> {
|
|
const text = readFileSync(path, "utf8");
|
|
if (github.format === "raw-token") return { [github.sourceKey]: text.trim() };
|
|
return parseEnvTextSafe(text);
|
|
}
|
|
|
|
function parseEnvTextSafe(text: string): Record<string, string> {
|
|
const values: Record<string, string> = {};
|
|
for (const rawLine of text.split(/\r?\n/u)) {
|
|
let line = rawLine.trim();
|
|
if (line.length === 0 || line.startsWith("#")) continue;
|
|
if (line.startsWith("export ")) line = line.slice("export ".length).trim();
|
|
const eq = line.indexOf("=");
|
|
if (eq <= 0) continue;
|
|
const key = line.slice(0, eq).trim();
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
values[key] = unquote(line.slice(eq + 1).trim());
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function unquote(value: string): string {
|
|
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
|
|
return value;
|
|
}
|
|
|
|
function fingerprintKeys(values: Record<string, string>, keys: string[]): string | null {
|
|
if (keys.some((key) => !values[key])) return null;
|
|
const hash = createHash("sha256");
|
|
for (const key of keys.slice().sort()) {
|
|
hash.update(key);
|
|
hash.update("\0");
|
|
hash.update(values[key]);
|
|
hash.update("\0");
|
|
}
|
|
return `sha256:${hash.digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
function fingerprintSecretParts(parts: Array<string | null>): string | null {
|
|
if (parts.some((part) => !part)) return null;
|
|
const hash = createHash("sha256");
|
|
for (const part of parts) {
|
|
hash.update(part ?? "");
|
|
hash.update("\0");
|
|
}
|
|
return `sha256:${hash.digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
|
|
function parseApplyOptions(args: string[]): ApplyOptions {
|
|
const commonArgs: string[] = [];
|
|
let confirm = false;
|
|
let dryRun = false;
|
|
let wait = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--confirm") confirm = true;
|
|
else if (arg === "--dry-run") dryRun = true;
|
|
else if (arg === "--wait") wait = true;
|
|
else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target" || arg === "--node") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
if (confirm && dryRun) throw new CliInputError("Gitea apply accepts only one of --confirm or --dry-run", {
|
|
code: "mutually-exclusive-options",
|
|
argument: "--confirm --dry-run",
|
|
usage: ["bun scripts/cli.ts platform-infra gitea apply --dry-run", "bun scripts/cli.ts platform-infra gitea apply --confirm"],
|
|
});
|
|
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
|
}
|
|
|
|
function parseMirrorWebhookStatusOptions(args: string[]): MirrorWebhookStatusOptions {
|
|
const mirrorArgs: string[] = [];
|
|
let deliveryId: string | null = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg !== "--delivery-id") {
|
|
mirrorArgs.push(arg);
|
|
continue;
|
|
}
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new CliInputError("--delivery-id requires a value", {
|
|
code: "missing-option-value",
|
|
argument: "--delivery-id",
|
|
usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node> [--repo <key>] --delivery-id <id>",
|
|
});
|
|
if (!/^[A-Za-z0-9._:-]{1,160}$/u.test(value)) throw new CliInputError("--delivery-id must be a bounded GitHub delivery identifier", {
|
|
code: "invalid-option-value",
|
|
argument: value,
|
|
usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node> [--repo <key>] --delivery-id <id>",
|
|
});
|
|
deliveryId = value;
|
|
index += 1;
|
|
}
|
|
return { ...parseMirrorOptions(mirrorArgs), deliveryId };
|
|
}
|
|
|
|
function parseMirrorOptions(args: string[]): MirrorOptions {
|
|
const commonArgs: string[] = [];
|
|
let confirm = false;
|
|
let repoKey: string | null = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--confirm") {
|
|
confirm = true;
|
|
} else if (arg === "--repo") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new CliInputError("--repo requires a value", {
|
|
code: "missing-option-value",
|
|
argument: "--repo",
|
|
usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node> --repo <key>",
|
|
});
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new CliInputError("--repo must be a simple repository key", {
|
|
code: "invalid-option-value",
|
|
argument: value,
|
|
usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target <node> --repo <key>",
|
|
});
|
|
repoKey = value;
|
|
index += 1;
|
|
} else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target" || arg === "--node") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
return { ...parseCommonOptions(commonArgs), confirm, repoKey };
|
|
}
|
|
|
|
function parseCommonOptions(args: string[]): CommonOptions {
|
|
let targetId: string | null = null;
|
|
let full = false;
|
|
let raw = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--target" || arg === "--node") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new CliInputError(`${arg} requires a value`, {
|
|
code: "missing-option-value",
|
|
argument: arg,
|
|
usage: "bun scripts/cli.ts platform-infra gitea <command> --target <node>",
|
|
});
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new CliInputError(`${arg} must be a simple target id`, {
|
|
code: "invalid-option-value",
|
|
argument: value,
|
|
usage: "bun scripts/cli.ts platform-infra gitea <command> --target <node>",
|
|
});
|
|
targetId = value;
|
|
index += 1;
|
|
} else if (arg === "--full") {
|
|
full = true;
|
|
} else if (arg === "--raw") {
|
|
raw = true;
|
|
full = true;
|
|
} else {
|
|
throw new CliInputError(`Unsupported Gitea option: ${arg}`, {
|
|
code: "unsupported-option",
|
|
argument: arg,
|
|
supported: ["--target", "--node", "--repo", "--delivery-id", "--full", "--raw"],
|
|
usage: "bun scripts/cli.ts platform-infra gitea help",
|
|
});
|
|
}
|
|
}
|
|
return { targetId, full, raw };
|
|
}
|
|
|
|
function giteaReadOnlyNextCommands(targetId: string): Record<string, string> {
|
|
return {
|
|
status: `bun scripts/cli.ts platform-infra gitea status --target ${targetId}`,
|
|
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${targetId}`,
|
|
mirrorStatus: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
|
|
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
|
|
fixAutomaticDelivery: PAC_AUTOMATIC_DELIVERY_REFERENCE,
|
|
};
|
|
}
|
|
|
|
function manifestObjectSummary(yaml: string): Array<Record<string, unknown>> {
|
|
const objects: Array<Record<string, unknown>> = [];
|
|
for (const doc of yaml.split(/^---$/mu)) {
|
|
const kind = doc.match(/^\s*kind:\s*([A-Za-z0-9._-]+)\s*$/mu)?.[1];
|
|
const name = doc.match(/^\s*name:\s*([A-Za-z0-9._-]+)\s*$/mu)?.[1];
|
|
const namespace = doc.match(/^\s*namespace:\s*([A-Za-z0-9._-]+)\s*$/mu)?.[1] ?? null;
|
|
if (kind !== undefined && name !== undefined) objects.push({ kind, name, namespace });
|
|
}
|
|
return objects;
|
|
}
|
|
|
|
function serviceDns(gitea: GiteaConfig, target: GiteaTarget): string {
|
|
return `${gitea.app.serviceName}.${target.namespace}.svc.cluster.local:${gitea.app.service.httpPort}`;
|
|
}
|
|
|
|
function serviceDnsFromObjects(app: Record<string, unknown>, target: Record<string, unknown>): string {
|
|
const service = record(app.service);
|
|
return `${stringValue(app.serviceName)}.${stringValue(target.namespace)}.svc.cluster.local:${stringValue(service.httpPort)}`;
|
|
}
|
|
|
|
|
|
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> : {};
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[] : [];
|
|
}
|
|
|
|
function stringValue(value: unknown, fallback = "-"): string {
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return fallback;
|
|
}
|
|
|
|
function boolText(value: unknown): string {
|
|
return value === true ? "true" : "false";
|
|
}
|
|
|
|
function compactLine(value: string): string {
|
|
const trimmed = value.replace(/\s+/gu, " ").trim();
|
|
return trimmed.length > 0 ? trimmed.slice(0, 220) : "-";
|
|
}
|
|
|
|
function table(headers: string[], rows: string[][]): string[] {
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
return [format(headers), format(headers.map((header, index) => "-".repeat(Math.max(header.length, widths[index])))), ...rows.map(format)];
|
|
}
|