2756 lines
123 KiB
TypeScript
2756 lines
123 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 type { RenderedCliResult } from "./output";
|
|
import {
|
|
capture,
|
|
compactCapture,
|
|
createYamlFieldReader,
|
|
fingerprintValues,
|
|
parseJsonOutput,
|
|
readYamlRecord,
|
|
shQuote,
|
|
} from "./platform-infra-ops-library";
|
|
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 { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
|
|
import type { GiteaWebhookIngressRetry } 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";
|
|
|
|
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 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";
|
|
const y = createYamlFieldReader(configLabel);
|
|
|
|
interface GiteaTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
role: string;
|
|
enabled: boolean;
|
|
createNamespace: boolean;
|
|
storageClassName: string;
|
|
publicExposureEnabled: boolean;
|
|
webhookSync: GiteaTargetWebhookSync | null;
|
|
}
|
|
|
|
interface GiteaTargetWebhookSync {
|
|
publicPath: string;
|
|
frpc: {
|
|
proxyName: string;
|
|
remotePort: number;
|
|
};
|
|
}
|
|
|
|
interface GiteaConfig {
|
|
version: number;
|
|
kind: "platform-infra-gitea";
|
|
metadata: {
|
|
id: string;
|
|
owner: string;
|
|
spec: string;
|
|
relatedIssues: number[];
|
|
};
|
|
defaults: {
|
|
targetId: string;
|
|
};
|
|
migration: {
|
|
role: string;
|
|
replaces: string;
|
|
parentConfigRef: string;
|
|
envReusePolicy: string;
|
|
buildPlane: string;
|
|
runtimePlane: string;
|
|
};
|
|
sourceAuthority: {
|
|
enabled: boolean;
|
|
stage: string;
|
|
statusAuthority: string;
|
|
firstCiConsumer: string;
|
|
credentials: {
|
|
sourceRoot: string;
|
|
admin: GiteaAdminCredential;
|
|
github: GiteaGithubCredential;
|
|
};
|
|
githubProxy: {
|
|
enabled: boolean;
|
|
url: string;
|
|
noProxy: string[];
|
|
};
|
|
webhookSync: GiteaWebhookSync;
|
|
responsibilities: GiteaSourceResponsibility[];
|
|
repositories: GiteaMirrorRepository[];
|
|
};
|
|
targets: GiteaTarget[];
|
|
app: {
|
|
name: string;
|
|
statefulSetName: string;
|
|
serviceName: string;
|
|
replicas: number;
|
|
image: {
|
|
repository: string;
|
|
tag: string;
|
|
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
};
|
|
service: {
|
|
type: "ClusterIP";
|
|
httpPort: number;
|
|
sshPort: number;
|
|
};
|
|
server: {
|
|
domain: string;
|
|
rootUrl: string;
|
|
sshDomain: string;
|
|
protocol: "http" | "https";
|
|
startSshServer: boolean;
|
|
};
|
|
publicExposure: PublicServiceExposure & { secretRoot: string };
|
|
database: {
|
|
type: "sqlite3";
|
|
path: string;
|
|
};
|
|
actions: {
|
|
enabled: boolean;
|
|
};
|
|
webhook: {
|
|
allowedHostList: string;
|
|
};
|
|
registration: {
|
|
disabled: boolean;
|
|
};
|
|
storage: {
|
|
data: { size: string; mountPath: string };
|
|
config: { size: string; mountPath: string };
|
|
};
|
|
securityContext: {
|
|
runAsUser: number;
|
|
runAsGroup: number;
|
|
fsGroup: number;
|
|
};
|
|
resources: {
|
|
requests: { cpu: string; memory: string };
|
|
limits: { cpu: string; memory: string };
|
|
};
|
|
probes: {
|
|
healthPath: string;
|
|
initialDelaySeconds: number;
|
|
periodSeconds: number;
|
|
timeoutSeconds: number;
|
|
failureThreshold: number;
|
|
};
|
|
};
|
|
validation: {
|
|
waitTimeoutSeconds: number;
|
|
healthPath: string;
|
|
};
|
|
}
|
|
|
|
interface GiteaAdminCredential {
|
|
sourceRef: string;
|
|
format: "line-pair";
|
|
usernameLine: number;
|
|
passwordLine: number;
|
|
requiredFor: string[];
|
|
}
|
|
|
|
interface GiteaGithubCredential {
|
|
transport: "https-token";
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
format: "env" | "raw-token";
|
|
requiredFor: string[];
|
|
}
|
|
|
|
interface GiteaWebhookSync {
|
|
enabled: boolean;
|
|
direction: "github-to-gitea";
|
|
responseBudgetMs: number;
|
|
publicPath: string;
|
|
events: string[];
|
|
ingressRetry: GiteaWebhookIngressRetry;
|
|
secret: {
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
createIfMissing: boolean;
|
|
};
|
|
bridge: {
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
secretName: string;
|
|
configMapName: string;
|
|
image: string;
|
|
replicas: number;
|
|
httpPort: number;
|
|
serviceAccountName: string;
|
|
shutdownGraceMs: number;
|
|
candidateGate: {
|
|
enabled: boolean;
|
|
configMapName: string;
|
|
jobName: string;
|
|
activeDeadlineSeconds: number;
|
|
ttlSecondsAfterFinished: number;
|
|
healthTimeoutMs: number;
|
|
pollIntervalMs: number;
|
|
};
|
|
inbox: {
|
|
claimName: string;
|
|
path: string;
|
|
storageSize: string;
|
|
maxBytes: number;
|
|
committedRetentionSeconds: number;
|
|
cleanupIntervalMs: number;
|
|
};
|
|
retry: {
|
|
maxAttempts: number;
|
|
initialDelayMs: number;
|
|
maxDelayMs: number;
|
|
terminalRetryDelayMs: number;
|
|
attemptTimeoutMs: number;
|
|
scanIntervalMs: number;
|
|
};
|
|
};
|
|
gitOpsDelivery: GiteaWebhookGitOpsDelivery;
|
|
frpc: {
|
|
proxyName: string;
|
|
remotePort: number;
|
|
};
|
|
}
|
|
|
|
export interface GiteaWebhookGitOpsDelivery {
|
|
enabled: boolean;
|
|
targetId: string;
|
|
readUrl: string;
|
|
writeUrl: string;
|
|
branch: string;
|
|
sourceSnapshotPrefix: string;
|
|
desiredManifestPath: string;
|
|
bootstrapApplicationPath: string;
|
|
releaseStatePath: string;
|
|
application: {
|
|
name: string;
|
|
namespace: string;
|
|
project: string;
|
|
repoUrl: string;
|
|
targetRevision: string;
|
|
path: string;
|
|
destinationNamespace: string;
|
|
automated: boolean;
|
|
};
|
|
author: {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
cas: {
|
|
maxAttempts: number;
|
|
};
|
|
}
|
|
|
|
interface GiteaSourceResponsibility {
|
|
name: string;
|
|
current: string;
|
|
target: string;
|
|
disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly";
|
|
}
|
|
|
|
interface GiteaMirrorRepository {
|
|
key: string;
|
|
targetId: string;
|
|
upstream: {
|
|
repository: string;
|
|
cloneUrl: string;
|
|
branch: string;
|
|
};
|
|
gitea: {
|
|
owner: string;
|
|
name: string;
|
|
mirrorMode: "controlled-push";
|
|
publicRead: boolean;
|
|
readUrl: string;
|
|
};
|
|
gitops: {
|
|
branch: string;
|
|
flushDisposition: string;
|
|
};
|
|
snapshot: {
|
|
prefix: string;
|
|
};
|
|
legacyGitMirror: {
|
|
readUrl: string;
|
|
configRef: string;
|
|
disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly";
|
|
};
|
|
}
|
|
|
|
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 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> [--full|--raw]",
|
|
],
|
|
scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"],
|
|
boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。",
|
|
};
|
|
}
|
|
|
|
function readGiteaConfig(): GiteaConfig {
|
|
const root = readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-gitea");
|
|
const version = y.integerField(root, "version", "");
|
|
if (version !== 1) throw new Error(`${configLabel}.version must be 1`);
|
|
const metadata = y.objectField(root, "metadata", "");
|
|
const defaults = y.objectField(root, "defaults", "");
|
|
const migration = y.objectField(root, "migration", "");
|
|
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");
|
|
const server = y.objectField(app, "server", "app");
|
|
const database = y.objectField(app, "database", "app");
|
|
const actions = y.objectField(app, "actions", "app");
|
|
const webhook = y.objectField(app, "webhook", "app");
|
|
const registration = y.objectField(app, "registration", "app");
|
|
const storage = y.objectField(app, "storage", "app");
|
|
const dataStorage = y.objectField(storage, "data", "app.storage");
|
|
const configStorage = y.objectField(storage, "config", "app.storage");
|
|
const publicExposure = y.objectField(app, "publicExposure", "app");
|
|
const securityContext = y.objectField(app, "securityContext", "app");
|
|
const resources = y.objectField(app, "resources", "app");
|
|
const requests = y.objectField(resources, "requests", "app.resources");
|
|
const limits = y.objectField(resources, "limits", "app.resources");
|
|
const probes = y.objectField(app, "probes", "app");
|
|
const validation = y.objectField(root, "validation", "");
|
|
const parsed: GiteaConfig = {
|
|
version,
|
|
kind: "platform-infra-gitea",
|
|
metadata: {
|
|
id: y.stringField(metadata, "id", "metadata"),
|
|
owner: y.stringField(metadata, "owner", "metadata"),
|
|
spec: y.stringField(metadata, "spec", "metadata"),
|
|
relatedIssues: y.numberArrayField(metadata, "relatedIssues", "metadata"),
|
|
},
|
|
defaults: {
|
|
targetId: y.stringField(defaults, "targetId", "defaults"),
|
|
},
|
|
migration: {
|
|
role: y.stringField(migration, "role", "migration"),
|
|
replaces: y.stringField(migration, "replaces", "migration"),
|
|
parentConfigRef: y.stringField(migration, "parentConfigRef", "migration"),
|
|
envReusePolicy: y.stringField(migration, "envReusePolicy", "migration"),
|
|
buildPlane: y.stringField(migration, "buildPlane", "migration"),
|
|
runtimePlane: y.stringField(migration, "runtimePlane", "migration"),
|
|
},
|
|
sourceAuthority: {
|
|
enabled: y.booleanField(sourceAuthority, "enabled", "sourceAuthority"),
|
|
stage: y.stringField(sourceAuthority, "stage", "sourceAuthority"),
|
|
statusAuthority: y.stringField(sourceAuthority, "statusAuthority", "sourceAuthority"),
|
|
firstCiConsumer: y.stringField(sourceAuthority, "firstCiConsumer", "sourceAuthority"),
|
|
credentials: {
|
|
sourceRoot: y.absolutePathField(sourceCredentials, "sourceRoot", "sourceAuthority.credentials"),
|
|
admin: parseAdminCredential(y.objectField(sourceCredentials, "admin", "sourceAuthority.credentials"), "sourceAuthority.credentials.admin"),
|
|
github: parseGithubCredential(y.objectField(sourceCredentials, "github", "sourceAuthority.credentials"), "sourceAuthority.credentials.github"),
|
|
},
|
|
githubProxy: {
|
|
enabled: y.booleanField(githubProxy, "enabled", "sourceAuthority.githubProxy"),
|
|
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),
|
|
},
|
|
targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget),
|
|
app: {
|
|
name: y.kubernetesNameField(app, "name", "app"),
|
|
statefulSetName: y.kubernetesNameField(app, "statefulSetName", "app"),
|
|
serviceName: y.kubernetesNameField(app, "serviceName", "app"),
|
|
replicas: positiveInteger(app, "replicas", "app"),
|
|
image: {
|
|
repository: y.stringField(image, "repository", "app.image"),
|
|
tag: y.stringField(image, "tag", "app.image"),
|
|
pullPolicy: y.enumField(image, "pullPolicy", "app.image", ["Always", "IfNotPresent", "Never"] as const),
|
|
},
|
|
service: {
|
|
type: y.enumField(service, "type", "app.service", ["ClusterIP"] as const),
|
|
httpPort: y.portField(service, "httpPort", "app.service"),
|
|
sshPort: y.portField(service, "sshPort", "app.service"),
|
|
},
|
|
server: {
|
|
domain: y.hostField(server, "domain", "app.server"),
|
|
rootUrl: urlField(server, "rootUrl", "app.server"),
|
|
sshDomain: y.hostField(server, "sshDomain", "app.server"),
|
|
protocol: y.enumField(server, "protocol", "app.server", ["http", "https"] as const),
|
|
startSshServer: y.booleanField(server, "startSshServer", "app.server"),
|
|
},
|
|
publicExposure: parsePublicExposure(publicExposure, "app.publicExposure"),
|
|
database: {
|
|
type: y.enumField(database, "type", "app.database", ["sqlite3"] as const),
|
|
path: y.absolutePathField(database, "path", "app.database"),
|
|
},
|
|
actions: {
|
|
enabled: y.booleanField(actions, "enabled", "app.actions"),
|
|
},
|
|
webhook: {
|
|
allowedHostList: y.stringField(webhook, "allowedHostList", "app.webhook"),
|
|
},
|
|
registration: {
|
|
disabled: y.booleanField(registration, "disabled", "app.registration"),
|
|
},
|
|
storage: {
|
|
data: { size: quantity(dataStorage, "size", "app.storage.data"), mountPath: y.absolutePathField(dataStorage, "mountPath", "app.storage.data") },
|
|
config: { size: quantity(configStorage, "size", "app.storage.config"), mountPath: y.absolutePathField(configStorage, "mountPath", "app.storage.config") },
|
|
},
|
|
securityContext: {
|
|
runAsUser: positiveInteger(securityContext, "runAsUser", "app.securityContext"),
|
|
runAsGroup: positiveInteger(securityContext, "runAsGroup", "app.securityContext"),
|
|
fsGroup: positiveInteger(securityContext, "fsGroup", "app.securityContext"),
|
|
},
|
|
resources: {
|
|
requests: { cpu: y.stringField(requests, "cpu", "app.resources.requests"), memory: quantity(requests, "memory", "app.resources.requests") },
|
|
limits: { cpu: y.stringField(limits, "cpu", "app.resources.limits"), memory: quantity(limits, "memory", "app.resources.limits") },
|
|
},
|
|
probes: {
|
|
healthPath: y.apiPathField(probes, "healthPath", "app.probes"),
|
|
initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "app.probes"),
|
|
periodSeconds: positiveInteger(probes, "periodSeconds", "app.probes"),
|
|
timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "app.probes"),
|
|
failureThreshold: positiveInteger(probes, "failureThreshold", "app.probes"),
|
|
},
|
|
},
|
|
validation: {
|
|
waitTimeoutSeconds: boundedTimeout(validation, "waitTimeoutSeconds", "validation"),
|
|
healthPath: y.apiPathField(validation, "healthPath", "validation"),
|
|
},
|
|
};
|
|
validateConfig(parsed);
|
|
return parsed;
|
|
}
|
|
|
|
function parseAdminCredential(record: Record<string, unknown>, path: string): GiteaAdminCredential {
|
|
return {
|
|
sourceRef: secretSourceRefField(record, "sourceRef", path),
|
|
format: literalField(record, "format", path, ["line-pair"]),
|
|
usernameLine: positiveInteger(record, "usernameLine", path),
|
|
passwordLine: positiveInteger(record, "passwordLine", path),
|
|
requiredFor: y.stringArrayField(record, "requiredFor", path),
|
|
};
|
|
}
|
|
|
|
function parseGithubCredential(record: Record<string, unknown>, path: string): GiteaGithubCredential {
|
|
return {
|
|
transport: y.enumField(record, "transport", path, ["https-token"] as const),
|
|
sourceRef: secretSourceRefField(record, "sourceRef", path),
|
|
sourceKey: y.envKeyField(record, "sourceKey", path),
|
|
format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env",
|
|
requiredFor: y.stringArrayField(record, "requiredFor", path),
|
|
};
|
|
}
|
|
|
|
function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaWebhookSync {
|
|
const ingressRetry = y.objectField(record, "ingressRetry", path);
|
|
const secret = y.objectField(record, "secret", path);
|
|
const bridge = y.objectField(record, "bridge", path);
|
|
const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`);
|
|
const inbox = y.objectField(bridge, "inbox", `${path}.bridge`);
|
|
const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path);
|
|
const frpc = y.objectField(record, "frpc", path);
|
|
return {
|
|
enabled: y.booleanField(record, "enabled", path),
|
|
direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const),
|
|
responseBudgetMs: positiveInteger(record, "responseBudgetMs", path),
|
|
publicPath: y.apiPathField(record, "publicPath", path),
|
|
events: y.stringArrayField(record, "events", path),
|
|
ingressRetry: {
|
|
enabled: y.booleanField(ingressRetry, "enabled", `${path}.ingressRetry`),
|
|
maxBodyBytes: positiveInteger(ingressRetry, "maxBodyBytes", `${path}.ingressRetry`),
|
|
maxRetries: positiveInteger(ingressRetry, "maxRetries", `${path}.ingressRetry`),
|
|
tryIntervalMs: positiveInteger(ingressRetry, "tryIntervalMs", `${path}.ingressRetry`),
|
|
dialTimeoutMs: positiveInteger(ingressRetry, "dialTimeoutMs", `${path}.ingressRetry`),
|
|
writeTimeoutMs: positiveInteger(ingressRetry, "writeTimeoutMs", `${path}.ingressRetry`),
|
|
retryStatusCodes: y.numberArrayField(ingressRetry, "retryStatusCodes", `${path}.ingressRetry`),
|
|
},
|
|
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`),
|
|
shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`),
|
|
candidateGate: {
|
|
enabled: y.booleanField(candidateGate, "enabled", `${path}.bridge.candidateGate`),
|
|
configMapName: y.kubernetesNameField(candidateGate, "configMapName", `${path}.bridge.candidateGate`),
|
|
jobName: y.kubernetesNameField(candidateGate, "jobName", `${path}.bridge.candidateGate`),
|
|
activeDeadlineSeconds: positiveInteger(candidateGate, "activeDeadlineSeconds", `${path}.bridge.candidateGate`),
|
|
ttlSecondsAfterFinished: positiveInteger(candidateGate, "ttlSecondsAfterFinished", `${path}.bridge.candidateGate`),
|
|
healthTimeoutMs: positiveInteger(candidateGate, "healthTimeoutMs", `${path}.bridge.candidateGate`),
|
|
pollIntervalMs: positiveInteger(candidateGate, "pollIntervalMs", `${path}.bridge.candidateGate`),
|
|
},
|
|
inbox: {
|
|
claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`),
|
|
path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`),
|
|
storageSize: quantity(inbox, "storageSize", `${path}.bridge.inbox`),
|
|
maxBytes: positiveInteger(inbox, "maxBytes", `${path}.bridge.inbox`),
|
|
committedRetentionSeconds: positiveInteger(inbox, "committedRetentionSeconds", `${path}.bridge.inbox`),
|
|
cleanupIntervalMs: positiveInteger(inbox, "cleanupIntervalMs", `${path}.bridge.inbox`),
|
|
},
|
|
retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`),
|
|
},
|
|
gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`),
|
|
frpc: {
|
|
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
|
|
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseWebhookGitOpsDelivery(record: Record<string, unknown>, path: string): GiteaWebhookGitOpsDelivery {
|
|
const application = y.objectField(record, "application", path);
|
|
const author = y.objectField(record, "author", path);
|
|
const cas = y.objectField(record, "cas", path);
|
|
return {
|
|
enabled: y.booleanField(record, "enabled", path),
|
|
targetId: y.stringField(record, "targetId", path),
|
|
readUrl: urlField(record, "readUrl", path),
|
|
writeUrl: urlField(record, "writeUrl", path),
|
|
branch: gitBranchField(record, "branch", path),
|
|
sourceSnapshotPrefix: refPrefixField(record, "sourceSnapshotPrefix", path),
|
|
desiredManifestPath: safeRelativePathField(record, "desiredManifestPath", path),
|
|
bootstrapApplicationPath: safeRelativePathField(record, "bootstrapApplicationPath", path),
|
|
releaseStatePath: safeRelativePathField(record, "releaseStatePath", path),
|
|
application: {
|
|
name: y.kubernetesNameField(application, "name", `${path}.application`),
|
|
namespace: y.kubernetesNameField(application, "namespace", `${path}.application`),
|
|
project: y.kubernetesNameField(application, "project", `${path}.application`),
|
|
repoUrl: urlField(application, "repoUrl", `${path}.application`),
|
|
targetRevision: gitBranchField(application, "targetRevision", `${path}.application`),
|
|
path: safeRelativePathField(application, "path", `${path}.application`),
|
|
destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", `${path}.application`),
|
|
automated: y.booleanField(application, "automated", `${path}.application`),
|
|
},
|
|
author: {
|
|
name: y.stringField(author, "name", `${path}.author`),
|
|
email: y.stringField(author, "email", `${path}.author`),
|
|
},
|
|
cas: {
|
|
maxAttempts: positiveInteger(cas, "maxAttempts", `${path}.cas`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseWebhookRetry(record: Record<string, unknown>, path: string): GiteaWebhookSync["bridge"]["retry"] {
|
|
return {
|
|
maxAttempts: positiveInteger(record, "maxAttempts", path),
|
|
initialDelayMs: positiveInteger(record, "initialDelayMs", path),
|
|
maxDelayMs: positiveInteger(record, "maxDelayMs", path),
|
|
terminalRetryDelayMs: positiveInteger(record, "terminalRetryDelayMs", path),
|
|
attemptTimeoutMs: positiveInteger(record, "attemptTimeoutMs", path),
|
|
scanIntervalMs: positiveInteger(record, "scanIntervalMs", path),
|
|
};
|
|
}
|
|
|
|
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);
|
|
const pk01 = y.objectField(record, "pk01", path);
|
|
const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path);
|
|
const hostname = y.hostField(dns, "hostname", `${path}.dns`);
|
|
if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`);
|
|
return {
|
|
enabled: y.booleanField(record, "enabled", path),
|
|
publicBaseUrl,
|
|
secretRoot: y.absolutePathField(record, "secretRoot", path),
|
|
dns: {
|
|
hostname,
|
|
expectedA: y.stringField(dns, "expectedA", `${path}.dns`),
|
|
resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`),
|
|
},
|
|
frpc: {
|
|
deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`),
|
|
secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`),
|
|
secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`),
|
|
image: y.stringField(frpc, "image", `${path}.frpc`),
|
|
serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`),
|
|
serverPort: y.portField(frpc, "serverPort", `${path}.frpc`),
|
|
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
|
|
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
|
|
localIP: y.hostField(frpc, "localIP", `${path}.frpc`),
|
|
localPort: y.portField(frpc, "localPort", `${path}.frpc`),
|
|
tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`),
|
|
tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`),
|
|
},
|
|
pk01: {
|
|
route: y.stringField(pk01, "route", `${path}.pk01`),
|
|
caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`),
|
|
caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`),
|
|
responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseResponsibility(record: Record<string, unknown>, index: number): GiteaSourceResponsibility {
|
|
const path = `sourceAuthority.responsibilities[${index}]`;
|
|
return {
|
|
name: y.stringField(record, "name", path),
|
|
current: y.stringField(record, "current", path),
|
|
target: y.stringField(record, "target", path),
|
|
disposition: y.enumField(record, "disposition", path, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const),
|
|
};
|
|
}
|
|
|
|
function parseMirrorRepository(record: Record<string, unknown>, index: number): GiteaMirrorRepository {
|
|
const path = `sourceAuthority.repositories[${index}]`;
|
|
const upstream = y.objectField(record, "upstream", path);
|
|
const gitea = y.objectField(record, "gitea", path);
|
|
const gitops = y.objectField(record, "gitops", path);
|
|
const snapshot = y.objectField(record, "snapshot", path);
|
|
const legacyGitMirror = y.objectField(record, "legacyGitMirror", path);
|
|
return {
|
|
key: y.stringField(record, "key", path),
|
|
targetId: y.stringField(record, "targetId", path),
|
|
upstream: {
|
|
repository: repositoryField(upstream, "repository", `${path}.upstream`),
|
|
cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`),
|
|
branch: y.stringField(upstream, "branch", `${path}.upstream`),
|
|
},
|
|
gitea: {
|
|
owner: y.stringField(gitea, "owner", `${path}.gitea`),
|
|
name: giteaRepoNameField(gitea, "name", `${path}.gitea`),
|
|
mirrorMode: y.enumField(gitea, "mirrorMode", `${path}.gitea`, ["controlled-push"] as const),
|
|
publicRead: y.booleanField(gitea, "publicRead", `${path}.gitea`),
|
|
readUrl: urlField(gitea, "readUrl", `${path}.gitea`),
|
|
},
|
|
gitops: {
|
|
branch: y.stringField(gitops, "branch", `${path}.gitops`),
|
|
flushDisposition: y.stringField(gitops, "flushDisposition", `${path}.gitops`),
|
|
},
|
|
snapshot: {
|
|
prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`),
|
|
},
|
|
legacyGitMirror: {
|
|
readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`),
|
|
configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`),
|
|
disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseTarget(record: Record<string, unknown>, index: number): GiteaTarget {
|
|
const path = `targets[${index}]`;
|
|
const publicExposureRaw = record.publicExposure;
|
|
if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) {
|
|
throw new Error(`${configLabel}.${path}.publicExposure must be an object`);
|
|
}
|
|
const webhookSyncRaw = record.webhookSync;
|
|
if (webhookSyncRaw !== undefined && (typeof webhookSyncRaw !== "object" || webhookSyncRaw === null || Array.isArray(webhookSyncRaw))) {
|
|
throw new Error(`${configLabel}.${path}.webhookSync must be an object`);
|
|
}
|
|
const publicExposure = publicExposureRaw as Record<string, unknown> | undefined;
|
|
return {
|
|
id: y.stringField(record, "id", path),
|
|
route: y.stringField(record, "route", path),
|
|
namespace: y.kubernetesNameField(record, "namespace", path),
|
|
role: y.stringField(record, "role", path),
|
|
enabled: y.booleanField(record, "enabled", path),
|
|
createNamespace: y.booleanField(record, "createNamespace", path),
|
|
storageClassName: y.stringField(record, "storageClassName", path),
|
|
publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`),
|
|
webhookSync: webhookSyncRaw === undefined ? null : parseTargetWebhookSync(webhookSyncRaw as Record<string, unknown>, `${path}.webhookSync`),
|
|
};
|
|
}
|
|
|
|
function parseTargetWebhookSync(record: Record<string, unknown>, path: string): GiteaTargetWebhookSync {
|
|
const frpc = y.objectField(record, "frpc", path);
|
|
return {
|
|
publicPath: y.apiPathField(record, "publicPath", path),
|
|
frpc: {
|
|
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
|
|
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateConfig(gitea: GiteaConfig): void {
|
|
resolveTarget(gitea, gitea.defaults.targetId);
|
|
if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`);
|
|
if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`);
|
|
if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`);
|
|
if (!/-rootless$/u.test(gitea.app.image.tag)) throw new Error(`${configLabel}.app.image.tag must use a rootless Gitea image`);
|
|
if (gitea.app.service.type !== "ClusterIP") throw new Error(`${configLabel}.app.service.type must stay ClusterIP`);
|
|
if (!gitea.app.actions.enabled) throw new Error(`${configLabel}.app.actions.enabled must stay explicit while Gitea is the source-authority service`);
|
|
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 ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
|
|
const bridge = gitea.sourceAuthority.webhookSync.bridge;
|
|
if (bridge.replicas !== 1) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.replicas must be 1 because the durable inbox uses a single-writer Recreate deployment`);
|
|
if (bridge.retry.terminalRetryDelayMs < bridge.retry.maxDelayMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.retry.terminalRetryDelayMs must be >= maxDelayMs`);
|
|
if (ingressRetry.retryStatusCodes.length < 1 || ingressRetry.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) {
|
|
throw new Error(`${configLabel}.sourceAuthority.webhookSync.ingressRetry.retryStatusCodes may only contain 502, 503 and 504`);
|
|
}
|
|
if (ingressRetry.enabled) {
|
|
try {
|
|
validateGiteaWebhookTiming(gitea.sourceAuthority.webhookSync.responseBudgetMs, ingressRetry);
|
|
} catch (error) {
|
|
throw new Error(`${configLabel}.sourceAuthority.webhookSync timing invalid: ${String((error as Error).message || error)}`);
|
|
}
|
|
}
|
|
const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery;
|
|
if (delivery.enabled) {
|
|
const target = resolveTarget(gitea, delivery.targetId);
|
|
if (target.id !== "NC01") throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.targetId must be NC01`);
|
|
if (delivery.application.targetRevision !== delivery.branch) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.targetRevision must match branch`);
|
|
if (delivery.application.repoUrl !== delivery.readUrl) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.repoUrl must match readUrl`);
|
|
if (dirname(delivery.desiredManifestPath) !== delivery.application.path) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must be directly under application.path`);
|
|
if (!delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.bootstrapApplicationPath must be under the existing unidesk-host bootstrap path`);
|
|
if (delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must use an independent GitOps path`);
|
|
if (!/^[^@\s]+@[^@\s]+$/u.test(delivery.author.email)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.author.email must be an email address`);
|
|
if (delivery.cas.maxAttempts > 5) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.cas.maxAttempts must be <= 5`);
|
|
}
|
|
}
|
|
const webhookPaths = new Set<string>();
|
|
const webhookPorts = new Set<number>();
|
|
for (const route of webhookCaddyRoutes(gitea)) {
|
|
if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`);
|
|
if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`);
|
|
if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`);
|
|
webhookPaths.add(route.publicPath);
|
|
webhookPorts.add(route.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}`);
|
|
repoKeys.add(repo.key);
|
|
resolveTarget(gitea, repo.targetId);
|
|
const readUrl = new URL(repo.gitea.readUrl);
|
|
if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`);
|
|
if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`);
|
|
}
|
|
}
|
|
|
|
function resolveTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget {
|
|
const resolved = targetId ?? gitea.defaults.targetId;
|
|
const target = gitea.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase());
|
|
if (target === undefined) throw new Error(`unknown gitea target ${resolved}; known targets: ${gitea.targets.map((item) => item.id).join(", ")}`);
|
|
if (!target.enabled) throw new Error(`gitea target ${target.id} is disabled in ${configLabel}`);
|
|
return target;
|
|
}
|
|
|
|
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 [--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 = parseMirrorOptions(args.slice(1));
|
|
const result = await mirrorWebhookStatus(config, options);
|
|
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
|
|
usage: "platform-infra gitea mirror webhook status --target <node> [--repo <key>] [--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: 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 }).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: github-token
|
|
- name: GITEA_USERNAME
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${sync.bridge.secretName}
|
|
key: gitea-username
|
|
- name: GITEA_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${sync.bridge.secretName}
|
|
key: gitea-password
|
|
- name: GITHUB_WEBHOOK_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${sync.bridge.secretName}
|
|
key: github-webhook-secret
|
|
volumeMounts:
|
|
- name: candidate-config
|
|
mountPath: /etc/gitea-github-sync-candidate
|
|
readOnly: true
|
|
- name: candidate-inbox
|
|
mountPath: ${sync.bridge.inbox.path}
|
|
volumes:
|
|
- name: candidate-config
|
|
configMap:
|
|
name: ${gate.configMapName}
|
|
- name: candidate-inbox
|
|
emptyDir: {}
|
|
` : "";
|
|
return `${candidateManifest}---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
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: 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
|
|
- 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_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 targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): GiteaWebhookSync {
|
|
const base = gitea.sourceAuthority.webhookSync;
|
|
if (target.webhookSync === null) return base;
|
|
return {
|
|
...base,
|
|
publicPath: target.webhookSync.publicPath,
|
|
frpc: target.webhookSync.frpc,
|
|
};
|
|
}
|
|
|
|
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 webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> {
|
|
const sync = gitea.sourceAuthority.webhookSync;
|
|
if (!sync.enabled) return [];
|
|
const routes = new Map<string, { publicPath: string; remotePort: number }>();
|
|
routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort });
|
|
for (const target of gitea.targets) {
|
|
if (!target.enabled || target.webhookSync === null) continue;
|
|
const targetSync = targetWebhookSync(gitea, target);
|
|
routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort });
|
|
}
|
|
return [...routes.values()];
|
|
}
|
|
|
|
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: ["github-token", "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 Error(`unknown gitea mirror repo ${repoKey}; known repos: ${repos.map((item) => item.key).join(", ")}`);
|
|
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 renderMirrorPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const source = record(result.sourceAuthority);
|
|
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.upstreamRepository), stringValue(repo.upstreamBranch), stringValue(repo.readUrl), stringValue(repo.legacyDisposition)]);
|
|
const responsibilities = arrayRecords(result.responsibilities).map((item) => [stringValue(item.name), stringValue(item.current), stringValue(item.target), stringValue(item.disposition)]);
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea mirror plan", [
|
|
"PLATFORM-INFRA GITEA MIRROR PLAN",
|
|
...table(["TARGET", "STAGE", "AUTHORITY", "REPOS"], [[stringValue(target.id), stringValue(source.stage), stringValue(source.statusAuthority), stringValue(source.repositoryCount)]]),
|
|
"",
|
|
"REPOSITORIES",
|
|
...(repos.length === 0 ? ["-"] : table(["KEY", "UPSTREAM", "BRANCH", "GITEA_READ_URL", "LEGACY"], repos)),
|
|
"",
|
|
"RESPONSIBILITIES",
|
|
...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)),
|
|
"",
|
|
"NEXT",
|
|
` status: ${stringValue(next.status)}`,
|
|
` full: ${stringValue(next.statusFull)}`,
|
|
` webhook-status: ${stringValue(next.webhookStatus)}`,
|
|
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
|
|
"",
|
|
"Disclosure: credentials are summarized by presence/fingerprint only.",
|
|
]);
|
|
}
|
|
|
|
function renderMirrorBootstrap(result: Record<string, unknown>): RenderedCliResult {
|
|
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]);
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea mirror bootstrap", [
|
|
"PLATFORM-INFRA GITEA MIRROR BOOTSTRAP",
|
|
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
|
|
"",
|
|
"REPOSITORIES",
|
|
...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)),
|
|
...remoteErrorLines(result),
|
|
"",
|
|
`NEXT ${stringValue(next.webhookStatus)}`,
|
|
]);
|
|
}
|
|
|
|
function renderMirrorSync(result: Record<string, unknown>): RenderedCliResult {
|
|
const bundles = arrayRecords(result.sourceBundles).map((bundle) => [stringValue(bundle.key), stringValue(bundle.sourceCommit).slice(0, 12), stringValue(bundle.snapshotRef), stringValue(bundle.readUrl)]);
|
|
const repos = arrayRecords(result.repositories).map((repo) => [
|
|
stringValue(repo.key),
|
|
boolText(repo.syncOk),
|
|
stringValue(repo.fetchRc),
|
|
stringValue(repo.pushRc),
|
|
stringValue(repo.gitopsPushRc),
|
|
stringValue(repo.sourceCommit).slice(0, 12),
|
|
compactTail(stringValue(repo.fetchTail) || stringValue(repo.pushTail) || stringValue(repo.revparseTail)),
|
|
]);
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea mirror sync", [
|
|
"PLATFORM-INFRA GITEA MIRROR SYNC",
|
|
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
|
|
"",
|
|
"REPOSITORIES",
|
|
...(repos.length === 0 ? ["-"] : table(["KEY", "SYNC", "FETCH", "PUSH", "GITOPS", "COMMIT", "ERROR"], repos)),
|
|
"",
|
|
"SOURCE BUNDLES",
|
|
...(bundles.length === 0 ? ["-"] : table(["KEY", "COMMIT", "SNAPSHOT_REF", "READ_URL"], bundles)),
|
|
...remoteErrorLines(result),
|
|
"",
|
|
`NEXT ${stringValue(next.status)}`,
|
|
]);
|
|
}
|
|
|
|
function renderMirrorStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
const source = record(result.sourceAuthority);
|
|
const service = record(result.serviceHealth);
|
|
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.mirrorState), stringValue(repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), stringValue(repo.snapshotRef)]);
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea mirror status", [
|
|
"PLATFORM-INFRA GITEA MIRROR STATUS",
|
|
...table(["TARGET", "SERVICE", "MIRROR_READY", "AUTHORITY"], [[stringValue(record(result.target).id), boolText(service.ok), boolText(source.mirrorReady), stringValue(source.statusAuthority)]]),
|
|
"",
|
|
"REPOSITORIES",
|
|
...(repos.length === 0 ? ["-"] : table(["KEY", "STATE", "BRANCH", "SNAPSHOT", "SNAPSHOT_REF"], repos)),
|
|
...remoteErrorLines(result),
|
|
"",
|
|
`NEXT ${stringValue(next.webhookStatus)}`,
|
|
]);
|
|
}
|
|
|
|
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) => {
|
|
const latestPush = record(repo.latestPushDelivery);
|
|
const latest = record(repo.latestDelivery);
|
|
const delivery = latestPush.event !== "" ? latestPush : latest;
|
|
const inbox = record(repo.durableInboxRecord);
|
|
const inboxResult = record(inbox.result);
|
|
const inboxState = stringValue(repo.durableInboxState, "missing");
|
|
const durableProof = repo.preDurableBootstrap === true
|
|
? "pre-durable-bootstrap"
|
|
: repo.durableInboxCommitted === true
|
|
? `${stringValue(inboxResult.disposition)}:${stringValue(inboxResult.sourceCommit).slice(0, 12)}`
|
|
: "-";
|
|
return [
|
|
stringValue(repo.key),
|
|
boolText(repo.hookReady),
|
|
stringValue(repo.githubHead).slice(0, 12),
|
|
stringValue(repo.branchCommit).slice(0, 12),
|
|
stringValue(repo.snapshotCommit).slice(0, 12),
|
|
boolText(repo.authorityMatches),
|
|
boolText(repo.bridgeEventStale),
|
|
`${stringValue(repo.deliveryId)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`,
|
|
inboxState,
|
|
durableProof,
|
|
compactTail(stringValue(repo.error)),
|
|
];
|
|
});
|
|
const bridge = record(result.bridge);
|
|
const bridgeLogs = record(result.bridgeLogs);
|
|
const retry = record(record(record(result.webhook).bridge).retry);
|
|
const retryText = `${stringValue(retry.maxAttempts)}x/${stringValue(retry.initialDelayMs)}-${stringValue(retry.maxDelayMs)}ms`;
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea mirror webhook status", [
|
|
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
|
|
...table(["TARGET", "BRIDGE", "READY", "RETRY", "URL", "LOGS"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), retryText, stringValue(record(result.webhook).publicUrl), `rc=${stringValue(bridgeLogs.exitCode)}`]]),
|
|
"",
|
|
"GITHUB HOOKS",
|
|
...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "STALE", "DELIVERY", "DURABLE", "PROOF", "ERROR"], repos)),
|
|
...remoteErrorLines(result),
|
|
"",
|
|
`NEXT ${stringValue(next.webhookStatusFull)}`,
|
|
]);
|
|
}
|
|
|
|
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
const config = record(result.config);
|
|
const target = record(config.target);
|
|
const app = record(config.app);
|
|
const migration = record(config.migration);
|
|
const policy = arrayRecords(result.policy);
|
|
const failed = policy.filter((item) => item.ok === false);
|
|
const next = record(result.next);
|
|
return rendered(result, "platform-infra gitea plan", [
|
|
"PLATFORM-INFRA GITEA PLAN",
|
|
...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [
|
|
["TARGET", stringValue(target.id), "route", stringValue(target.route)],
|
|
["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)],
|
|
["IMAGE", stringValue(app.image), "replicas", stringValue(app.replicas)],
|
|
["SERVICE", `${stringValue(app.serviceName)}:${stringValue(record(app.service).httpPort)}`, "dns", serviceDnsFromObjects(app, target)],
|
|
["WEBUI", stringValue(record(app.publicExposure).publicBaseUrl), "internalGit", serviceDnsFromObjects(app, target)],
|
|
["ACTIONS", boolText(app.actionsEnabled), "registrationDisabled", boolText(app.registrationDisabled)],
|
|
["MIGRATION", stringValue(migration.role), "replaces", stringValue(migration.replaces)],
|
|
["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"],
|
|
]),
|
|
"",
|
|
"NEXT",
|
|
` status: ${stringValue(next.status)}`,
|
|
` validate: ${stringValue(next.validate)}`,
|
|
` mirror-status: ${stringValue(next.mirrorStatus)}`,
|
|
` webhook-status: ${stringValue(next.webhookStatus)}`,
|
|
` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`,
|
|
"",
|
|
"Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.",
|
|
"Disclosure: Secret values are not printed; this stage does not create runner credentials.",
|
|
]);
|
|
}
|
|
|
|
function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const payload = record(result.payloadTransport);
|
|
const remote = record(result.remote);
|
|
const steps = record(remote.steps);
|
|
const serverDryRunStep = record(steps.serverDryRun);
|
|
const frpcStep = record(steps.frpcSecret);
|
|
const frpcCleanupStep = record(steps.frpcCleanup);
|
|
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",
|
|
...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]),
|
|
...table(["TRANSPORT", "BYTES", "MATERIALIZATION", "VALUES"], [[stringValue(payload.kind), stringValue(payload.totalBytes), stringValue(payload.materialization), stringValue(payload.valuesPrinted)]]),
|
|
"",
|
|
"STEPS",
|
|
...table(["STEP", "EXIT", "DETAIL"], [
|
|
["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))],
|
|
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
|
|
["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.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),
|
|
"",
|
|
`NEXT bun scripts/cli.ts platform-infra gitea status --target ${stringValue(target.id)}`,
|
|
]);
|
|
}
|
|
|
|
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
const summary = record(result.summary);
|
|
const statefulSet = record(summary.statefulSet);
|
|
const service = record(summary.service);
|
|
const networkPolicy = record(summary.networkPolicy);
|
|
const pods = arrayRecords(summary.pods).map((pod) => [stringValue(pod.name), stringValue(pod.phase), boolText(pod.ready), stringValue(pod.restarts)]);
|
|
const pvcs = arrayRecords(summary.pvcs).map((pvc) => [stringValue(pvc.name), stringValue(pvc.phase), stringValue(pvc.capacity)]);
|
|
return rendered(result, "platform-infra gitea status", [
|
|
"PLATFORM-INFRA GITEA STATUS",
|
|
...table(["TARGET", "ROUTE", "NAMESPACE", "READY"], [[stringValue(summary.target), stringValue(summary.route), stringValue(summary.namespace), boolText(summary.ready)]]),
|
|
"",
|
|
"CONTROL",
|
|
...table(["CHECK", "VALUE", "DETAIL"], [
|
|
["statefulSet", `${stringValue(statefulSet.readyReplicas)}/${stringValue(statefulSet.desired)}`, stringValue(statefulSet.name)],
|
|
["service", stringValue(service.type), stringValue(summary.serviceDns)],
|
|
["endpoints", boolText(summary.endpointsReady), stringValue(service.clusterIP)],
|
|
["allow-all", boolText(networkPolicy.allowAllPresent), "NetworkPolicy"],
|
|
]),
|
|
"",
|
|
"PODS",
|
|
...(pods.length === 0 ? ["-"] : table(["POD", "PHASE", "READY", "RESTARTS"], pods)),
|
|
"",
|
|
"PVCS",
|
|
...(pvcs.length === 0 ? ["-"] : table(["PVC", "PHASE", "CAPACITY"], pvcs)),
|
|
...remoteErrorLines(result),
|
|
"",
|
|
`NEXT bun scripts/cli.ts platform-infra gitea validate --target ${stringValue(summary.target)}`,
|
|
]);
|
|
}
|
|
|
|
function remoteErrorLines(result: Record<string, unknown>): string[] {
|
|
if (result.ok !== false) return [];
|
|
const remote = record(result.remote);
|
|
const exitCode = stringValue(remote.exitCode);
|
|
const stderr = compactLine(stringValue(remote.stderrTail));
|
|
const stdout = compactLine(stringValue(remote.stdoutTail));
|
|
const detail = stderr !== "-" ? stderr : stdout;
|
|
return ["", "ERROR", ...table(["EXIT", "DETAIL"], [[exitCode, detail]])];
|
|
}
|
|
|
|
function compactTail(value: string): string {
|
|
const line = value.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean).at(-1) ?? "";
|
|
return line.length > 96 ? `${line.slice(0, 93)}...` : line;
|
|
}
|
|
|
|
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
|
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
}
|
|
|
|
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 Error("gitea apply accepts only one of --confirm or --dry-run");
|
|
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
|
}
|
|
|
|
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 Error("--repo requires a value");
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--repo must be a simple 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 Error(`${arg} requires a value`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple target id`);
|
|
targetId = value;
|
|
index += 1;
|
|
} else if (arg === "--full") {
|
|
full = true;
|
|
} else if (arg === "--raw") {
|
|
raw = true;
|
|
full = true;
|
|
} else {
|
|
throw new Error(`unsupported gitea option: ${arg}`);
|
|
}
|
|
}
|
|
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 positiveInteger(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = y.integerField(obj, key, path);
|
|
if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`);
|
|
return value;
|
|
}
|
|
|
|
function boundedTimeout(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = positiveInteger(obj, key, path);
|
|
if (value > 55) throw new Error(`${configLabel}.${path}.${key} must fit the 60s trans short-connection budget`);
|
|
return value;
|
|
}
|
|
|
|
function literalField<T extends string>(obj: Record<string, unknown>, key: string, path: string, allowed: T[]): T {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`);
|
|
return value as T;
|
|
}
|
|
|
|
function optionalLiteralField<T extends string>(obj: Record<string, unknown>, key: string, path: string, allowed: readonly T[]): T | null {
|
|
if (obj[key] === undefined || obj[key] === null) return null;
|
|
const value = y.stringField(obj, key, path);
|
|
if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`);
|
|
return value as T;
|
|
}
|
|
|
|
function quantity(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`);
|
|
return value;
|
|
}
|
|
|
|
function secretSourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a source ref path without ..`);
|
|
return value;
|
|
}
|
|
|
|
function repositoryField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be owner/repo`);
|
|
return value;
|
|
}
|
|
|
|
function giteaRepoNameField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!/^[A-Za-z0-9_.-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a Gitea repository name`);
|
|
return value;
|
|
}
|
|
|
|
function gitCloneUrlField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = urlField(obj, key, path);
|
|
if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an official GitHub HTTPS clone URL`);
|
|
return value;
|
|
}
|
|
|
|
function refPrefixField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a git ref prefix`);
|
|
return value.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function urlField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
const parsed = new URL(value);
|
|
if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`);
|
|
return value;
|
|
}
|
|
|
|
function gitBranchField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (!/^[A-Za-z0-9._/-]+$/u.test(value) || value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//")) {
|
|
throw new Error(`${configLabel}.${path}.${key} must be a safe Git branch name`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safeRelativePathField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
if (value.startsWith("/") || value.endsWith("/") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) {
|
|
throw new Error(`${configLabel}.${path}.${key} must be a safe relative path`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function httpsUrlField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = urlField(obj, key, path);
|
|
if (new URL(value).protocol !== "https:") throw new Error(`${configLabel}.${path}.${key} must be an https URL`);
|
|
return value;
|
|
}
|
|
|
|
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)];
|
|
}
|