942 lines
47 KiB
TypeScript
942 lines
47 KiB
TypeScript
import { dirname } from "node:path";
|
|
import { rootPath } from "./config";
|
|
import { createYamlFieldReader, readYamlRecord } from "./platform-infra-ops-library";
|
|
import type { PublicServiceExposure } from "./platform-infra-public-service";
|
|
import { validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
|
|
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy";
|
|
|
|
export const GITEA_CONFIG_LABEL = "config/platform-infra/gitea.yaml";
|
|
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
|
|
const configLabel = GITEA_CONFIG_LABEL;
|
|
const y = createYamlFieldReader(configLabel);
|
|
|
|
export interface GiteaTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
role: string;
|
|
enabled: boolean;
|
|
createNamespace: boolean;
|
|
storageClassName: string;
|
|
publicExposureEnabled: boolean;
|
|
webhookSync: GiteaTargetWebhookSync | null;
|
|
}
|
|
|
|
export interface GiteaTargetWebhookSync {
|
|
publicPath: string;
|
|
frpc: {
|
|
proxyName: string;
|
|
remotePort: number;
|
|
};
|
|
}
|
|
|
|
export 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;
|
|
};
|
|
}
|
|
|
|
export interface GiteaAdminCredential {
|
|
sourceRef: string;
|
|
format: "line-pair";
|
|
usernameLine: number;
|
|
passwordLine: number;
|
|
requiredFor: string[];
|
|
}
|
|
|
|
export interface GiteaGithubCredential {
|
|
transport: "https-token";
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
format: "env" | "raw-token";
|
|
requiredFor: string[];
|
|
gitFetchCredential: {
|
|
apiVersion: "unidesk.ai/v1";
|
|
kind: "GitFetchCredential";
|
|
authMode: "github-https-token";
|
|
host: "github.com";
|
|
secretRef: {
|
|
namespace: string;
|
|
name: string;
|
|
key: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
export interface GiteaWebhookSync {
|
|
enabled: boolean;
|
|
direction: "github-to-gitea";
|
|
responseBudgetMs: number;
|
|
publicPath: string;
|
|
events: string[];
|
|
ingressRetry: GiteaWebhookIngressRetry;
|
|
secret: {
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
createIfMissing: boolean;
|
|
};
|
|
hookReconcile: {
|
|
enabled: boolean;
|
|
ownerTargetId: string;
|
|
intervalMs: number;
|
|
requestTimeoutMs: number;
|
|
listMaxPages: number;
|
|
failedDeliveryRecovery: {
|
|
enabled: boolean;
|
|
targetId: string;
|
|
statePath: string;
|
|
lookbackSeconds: number;
|
|
historyPerHook: number;
|
|
maxRedeliveriesPerCycle: number;
|
|
cooldownMs: number;
|
|
maxAttemptsPerDelivery: number;
|
|
retryStatusCodes: number[];
|
|
stateRetentionSeconds: number;
|
|
};
|
|
};
|
|
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;
|
|
};
|
|
}
|
|
|
|
export interface GiteaSourceResponsibility {
|
|
name: string;
|
|
current: string;
|
|
target: string;
|
|
disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly";
|
|
}
|
|
|
|
export 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";
|
|
};
|
|
}
|
|
|
|
export 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 {
|
|
const gitFetchCredential = y.objectField(record, "gitFetchCredential", path);
|
|
const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`);
|
|
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),
|
|
gitFetchCredential: {
|
|
apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const),
|
|
kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const),
|
|
authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const),
|
|
host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const),
|
|
secretRef: {
|
|
namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`),
|
|
name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`),
|
|
key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaWebhookSync {
|
|
const ingressRetry = y.objectField(record, "ingressRetry", path);
|
|
const secret = y.objectField(record, "secret", path);
|
|
const hookReconcile = y.objectField(record, "hookReconcile", path);
|
|
const failedDeliveryRecovery = y.objectField(hookReconcile, "failedDeliveryRecovery", `${path}.hookReconcile`);
|
|
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`),
|
|
},
|
|
hookReconcile: {
|
|
enabled: y.booleanField(hookReconcile, "enabled", `${path}.hookReconcile`),
|
|
ownerTargetId: y.stringField(hookReconcile, "ownerTargetId", `${path}.hookReconcile`),
|
|
intervalMs: positiveInteger(hookReconcile, "intervalMs", `${path}.hookReconcile`),
|
|
requestTimeoutMs: positiveInteger(hookReconcile, "requestTimeoutMs", `${path}.hookReconcile`),
|
|
listMaxPages: positiveInteger(hookReconcile, "listMaxPages", `${path}.hookReconcile`),
|
|
failedDeliveryRecovery: {
|
|
enabled: y.booleanField(failedDeliveryRecovery, "enabled", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
targetId: y.stringField(failedDeliveryRecovery, "targetId", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
statePath: y.absolutePathField(failedDeliveryRecovery, "statePath", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
lookbackSeconds: positiveInteger(failedDeliveryRecovery, "lookbackSeconds", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
historyPerHook: positiveInteger(failedDeliveryRecovery, "historyPerHook", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
maxRedeliveriesPerCycle: positiveInteger(failedDeliveryRecovery, "maxRedeliveriesPerCycle", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
cooldownMs: positiveInteger(failedDeliveryRecovery, "cooldownMs", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
maxAttemptsPerDelivery: positiveInteger(failedDeliveryRecovery, "maxAttemptsPerDelivery", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
retryStatusCodes: y.numberArrayField(failedDeliveryRecovery, "retryStatusCodes", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
stateRetentionSeconds: positiveInteger(failedDeliveryRecovery, "stateRetentionSeconds", `${path}.hookReconcile.failedDeliveryRecovery`),
|
|
},
|
|
},
|
|
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);
|
|
const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential;
|
|
if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) {
|
|
throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`);
|
|
}
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) {
|
|
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`);
|
|
}
|
|
if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) {
|
|
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`);
|
|
}
|
|
if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) {
|
|
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`);
|
|
}
|
|
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 hookReconcile = gitea.sourceAuthority.webhookSync.hookReconcile;
|
|
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 (hookReconcile.enabled) {
|
|
const owner = resolveTarget(gitea, hookReconcile.ownerTargetId);
|
|
if (!gitea.sourceAuthority.repositories.some((repo) => repo.targetId.toLowerCase() === owner.id.toLowerCase())) {
|
|
throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.ownerTargetId must own at least one repository`);
|
|
}
|
|
if (hookReconcile.intervalMs < 30_000) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.intervalMs must be >= 30000`);
|
|
if (hookReconcile.requestTimeoutMs > 30_000) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.requestTimeoutMs must be <= 30000`);
|
|
if (hookReconcile.listMaxPages > 20) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.listMaxPages must be <= 20`);
|
|
const recovery = hookReconcile.failedDeliveryRecovery;
|
|
if (recovery.enabled) {
|
|
if (recovery.targetId.toLowerCase() !== owner.id.toLowerCase()) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.targetId must equal the owning target`);
|
|
const recoveryRoot = `${bridge.inbox.path.replace(/\/+$/u, "")}/hook-recovery/`;
|
|
if (!recovery.statePath.startsWith(recoveryRoot)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.statePath must be under ${recoveryRoot}`);
|
|
if (recovery.historyPerHook > 100) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.historyPerHook must be <= 100`);
|
|
if (recovery.lookbackSeconds > 259_200) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.lookbackSeconds must be <= 259200 because GitHub only supports redelivery for three days`);
|
|
if (recovery.maxRedeliveriesPerCycle > 20) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.maxRedeliveriesPerCycle must be <= 20`);
|
|
if (recovery.maxAttemptsPerDelivery > 10) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.maxAttemptsPerDelivery must be <= 10`);
|
|
if (recovery.cooldownMs < hookReconcile.intervalMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.cooldownMs must be >= hookReconcile.intervalMs`);
|
|
if (recovery.stateRetentionSeconds < recovery.lookbackSeconds) throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.stateRetentionSeconds must be >= lookbackSeconds`);
|
|
if (recovery.retryStatusCodes.length < 1 || recovery.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) {
|
|
throw new Error(`${configLabel}.sourceAuthority.webhookSync.hookReconcile.failedDeliveryRecovery.retryStatusCodes may only contain 502, 503 and 504`);
|
|
}
|
|
}
|
|
}
|
|
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`);
|
|
}
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export 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 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;
|
|
}
|