|
|
|
@@ -0,0 +1,898 @@
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import type { UniDeskConfig } from "./config";
|
|
|
|
|
import { rootPath } from "./config";
|
|
|
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
|
import {
|
|
|
|
|
capture,
|
|
|
|
|
compactCapture,
|
|
|
|
|
createYamlFieldReader,
|
|
|
|
|
parseJsonOutput,
|
|
|
|
|
readYamlRecord,
|
|
|
|
|
shQuote,
|
|
|
|
|
} from "./platform-infra-ops-library";
|
|
|
|
|
|
|
|
|
|
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 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
database: {
|
|
|
|
|
type: "sqlite3";
|
|
|
|
|
path: string;
|
|
|
|
|
};
|
|
|
|
|
actions: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
};
|
|
|
|
|
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 CommonOptions {
|
|
|
|
|
targetId: string | null;
|
|
|
|
|
full: boolean;
|
|
|
|
|
raw: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ApplyOptions extends CommonOptions {
|
|
|
|
|
confirm: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
wait: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
|
|
|
const [action = "plan"] = args;
|
|
|
|
|
if (action === "help" || action === "--help") return giteaHelp();
|
|
|
|
|
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)));
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "unsupported-platform-infra-gitea-command",
|
|
|
|
|
args,
|
|
|
|
|
help: giteaHelp(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function giteaHelp(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
command: "platform-infra gitea plan|apply|status|validate",
|
|
|
|
|
configTruth: configLabel,
|
|
|
|
|
usage: [
|
|
|
|
|
"bun scripts/cli.ts platform-infra gitea plan --target JD01",
|
|
|
|
|
"bun scripts/cli.ts platform-infra gitea apply --target JD01 --dry-run",
|
|
|
|
|
"bun scripts/cli.ts platform-infra gitea apply --target JD01 --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra gitea status --target JD01 [--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra gitea validate --target JD01 [--full|--raw]",
|
|
|
|
|
],
|
|
|
|
|
boundary: "Gitea is installed as an internal ClusterIP source-authority service for GH-1548/GH-1549; runner registration and repository mirror bootstrap are later controlled stages.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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 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 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"),
|
|
|
|
|
},
|
|
|
|
|
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"),
|
|
|
|
|
},
|
|
|
|
|
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"),
|
|
|
|
|
},
|
|
|
|
|
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 parseTarget(record: Record<string, unknown>, index: number): GiteaTarget {
|
|
|
|
|
const path = `targets[${index}]`;
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateConfig(gitea: GiteaConfig): void {
|
|
|
|
|
resolveTarget(gitea, gitea.defaults.targetId);
|
|
|
|
|
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 be true for GH-1548/GH-1549`);
|
|
|
|
|
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`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: nextCommands(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 result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
|
|
|
action: "platform-infra-gitea-apply",
|
|
|
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
|
|
|
mutation: !options.dryRun,
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
config: compactConfigSummary(gitea, target),
|
|
|
|
|
policy,
|
|
|
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
|
|
|
next: nextCommands(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 }));
|
|
|
|
|
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: {
|
|
|
|
|
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${target.id} --confirm`,
|
|
|
|
|
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${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 }));
|
|
|
|
|
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 }),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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__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", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions): string {
|
|
|
|
|
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_MANIFEST_B64: Buffer.from(manifest, "utf8").toString("base64"),
|
|
|
|
|
};
|
|
|
|
|
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
|
|
|
|
|
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string): Array<Record<string, unknown>> {
|
|
|
|
|
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: "gh-1548-service-contract", ok: target.namespace === "devops-infra" && gitea.app.serviceName === "gitea-http", detail: "The service matches config/cicd-gitea-actions-poc.yaml sourceAuthority.giteaMirror." },
|
|
|
|
|
{ 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-enabled", ok: gitea.app.actions.enabled, detail: "Gitea Actions is enabled; runner registration is a later controlled stage." },
|
|
|
|
|
{ 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." },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
actionsEnabled: gitea.app.actions.enabled,
|
|
|
|
|
registrationDisabled: gitea.app.registration.disabled,
|
|
|
|
|
storage: gitea.app.storage,
|
|
|
|
|
healthPath: gitea.validation.healthPath,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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)],
|
|
|
|
|
["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",
|
|
|
|
|
` dry-run: ${stringValue(next.dryRun)}`,
|
|
|
|
|
` apply: ${stringValue(next.apply)}`,
|
|
|
|
|
` status: ${stringValue(next.status)}`,
|
|
|
|
|
` validate: ${stringValue(next.validate)}`,
|
|
|
|
|
"",
|
|
|
|
|
"Boundary: Gitea is internal ClusterIP source authority for GH-1548/GH-1549; runner and mirror repo bootstrap are separate controlled stages.",
|
|
|
|
|
"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 remote = record(result.remote);
|
|
|
|
|
const steps = record(remote.steps);
|
|
|
|
|
const applyStep = record(steps.apply);
|
|
|
|
|
const rolloutStep = record(steps.rollout);
|
|
|
|
|
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)]]),
|
|
|
|
|
"",
|
|
|
|
|
"STEPS",
|
|
|
|
|
...table(["STEP", "EXIT", "DETAIL"], [
|
|
|
|
|
["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))],
|
|
|
|
|
["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))],
|
|
|
|
|
]),
|
|
|
|
|
...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 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 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 nextCommands(targetId: string): Record<string, string> {
|
|
|
|
|
return {
|
|
|
|
|
dryRun: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --dry-run`,
|
|
|
|
|
apply: `bun scripts/cli.ts platform-infra gitea apply --target ${targetId} --confirm`,
|
|
|
|
|
status: `bun scripts/cli.ts platform-infra gitea status --target ${targetId}`,
|
|
|
|
|
validate: `bun scripts/cli.ts platform-infra gitea validate --target ${targetId}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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 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 yamlQuote(value: string): string {
|
|
|
|
|
return JSON.stringify(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)];
|
|
|
|
|
}
|