diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml new file mode 100644 index 00000000..08767f6d --- /dev/null +++ b/config/platform-infra/gitea.yaml @@ -0,0 +1,85 @@ +version: 1 +kind: platform-infra-gitea + +metadata: + id: gitea-internal-mirror + owner: unidesk + spec: GH-1548 + relatedIssues: + - 1548 + - 1549 + +defaults: + targetId: JD01 + +migration: + role: gitea-actions-driven-cicd-source-authority + replaces: branch-follower-self-maintained-branch-observer + parentConfigRef: config/cicd-gitea-actions-poc.yaml#spec.sourceAuthority.giteaMirror + envReusePolicy: preserve-existing-runtime-env-reuse + buildPlane: controlled-docker-or-buildkit-outside-runtime + runtimePlane: k3s-gitea-service-zero-docker + +targets: + - id: JD01 + route: JD01:k3s + namespace: devops-infra + role: active-poc + enabled: true + createNamespace: true + storageClassName: local-path + +app: + name: gitea + statefulSetName: gitea + serviceName: gitea-http + replicas: 1 + image: + repository: docker.gitea.com/gitea + tag: 1.26.4-rootless + pullPolicy: IfNotPresent + service: + type: ClusterIP + httpPort: 3000 + sshPort: 2222 + server: + domain: gitea-http.devops-infra.svc.cluster.local + rootUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/ + sshDomain: gitea-http.devops-infra.svc.cluster.local + protocol: http + startSshServer: true + database: + type: sqlite3 + path: /var/lib/gitea/gitea.db + actions: + enabled: true + registration: + disabled: true + storage: + data: + size: 8Gi + mountPath: /var/lib/gitea + config: + size: 1Gi + mountPath: /etc/gitea + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + probes: + healthPath: /api/healthz + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 12 + +validation: + waitTimeoutSeconds: 55 + healthPath: /api/healthz diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 73fcdb9c..d1832530 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -661,7 +661,7 @@ function agentRunHelpSummary(): unknown { function platformInfraHelpSummary(): unknown { return { - command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive ...", + command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive|gitea ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan", @@ -689,8 +689,12 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/...", "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full", "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", + "bun scripts/cli.ts platform-infra gitea plan --target JD01", + "bun scripts/cli.ts platform-infra gitea apply --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea status --target JD01", + "bun scripts/cli.ts platform-infra gitea validate --target JD01", ], - description: "Operate G14 platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, and the YAML-controlled Codex pool.", + description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.", }; } diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh new file mode 100644 index 00000000..29974a4a --- /dev/null +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -0,0 +1,246 @@ +#!/bin/sh +set -u + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +json_tail() { + name="$1" + limit="${2:-2000}" + python3 - "$tmp/$name" "$limit" <<'PY' +import json, sys +path, limit = sys.argv[1], int(sys.argv[2]) +try: + data = open(path, encoding="utf-8", errors="replace").read()[-limit:] +except FileNotFoundError: + data = "" +print(json.dumps(data)) +PY +} + +capture_json() { + name="$1" + shift + "$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err" + rc=$? + printf '%s' "$rc" >"$tmp/$name.rc" +} + +run_apply() { + manifest="$tmp/gitea.k8s.yaml" + printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest" + dry_arg="" + if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then + dry_arg="--dry-run=server" + fi + timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side $dry_arg --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" + apply_rc=$? + if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then + timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "statefulset/$UNIDESK_GITEA_STATEFULSET_NAME" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >"$tmp/rollout.out" 2>"$tmp/rollout.err" + rollout_rc=$? + else + rollout_rc=0 + : >"$tmp/rollout.out" + if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then + printf '%s\n' "dry-run: rollout wait skipped" >"$tmp/rollout.err" + else + printf '%s\n' "rollout wait not requested" >"$tmp/rollout.err" + fi + fi + python3 - "$apply_rc" "$rollout_rc" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY' +import json, os, sys +apply_rc, rollout_rc = int(sys.argv[1]), int(sys.argv[2]) +def text(path, limit=3000): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1" +payload = { + "ok": apply_rc == 0 and rollout_rc == 0, + "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), + "route": os.environ.get("UNIDESK_GITEA_ROUTE"), + "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), + "mode": "dry-run" if dry else "confirmed", + "mutation": not dry, + "image": os.environ.get("UNIDESK_GITEA_IMAGE"), + "objects": { + "statefulSet": os.environ.get("UNIDESK_GITEA_STATEFULSET_NAME"), + "service": os.environ.get("UNIDESK_GITEA_SERVICE_NAME"), + "networkPolicy": "allow-all", + }, + "steps": { + "apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[3]), "stderrTail": text(sys.argv[4])}, + "rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])}, + }, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +} + +run_status() { + selector="app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" + capture_json namespace kubectl get namespace "$UNIDESK_GITEA_NAMESPACE" + capture_json networkpolicy kubectl -n "$UNIDESK_GITEA_NAMESPACE" get networkpolicy allow-all + capture_json statefulset kubectl -n "$UNIDESK_GITEA_NAMESPACE" get statefulset "$UNIDESK_GITEA_STATEFULSET_NAME" + capture_json service kubectl -n "$UNIDESK_GITEA_NAMESPACE" get service "$UNIDESK_GITEA_SERVICE_NAME" + capture_json endpoints kubectl -n "$UNIDESK_GITEA_NAMESPACE" get endpoints "$UNIDESK_GITEA_SERVICE_NAME" + capture_json pods kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pods -l "$selector" + capture_json pvc kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pvc -l "$selector" + capture_json events kubectl -n "$UNIDESK_GITEA_NAMESPACE" get events --sort-by=.lastTimestamp + python3 - "$tmp" <<'PY' +import json, os, sys +tmp = sys.argv[1] +def rc(name): + try: + return int(open(f"{tmp}/{name}.rc", encoding="utf-8").read() or "1") + except FileNotFoundError: + return 1 +def load(name): + try: + return json.load(open(f"{tmp}/{name}.json", encoding="utf-8")) + except Exception: + return None +def items(name): + data = load(name) + if isinstance(data, dict) and isinstance(data.get("items"), list): + return data["items"] + if isinstance(data, dict) and data.get("kind") != "Status": + return [data] + return [] +def meta_name(item): + return ((item or {}).get("metadata") or {}).get("name") +def pod_ready(item): + status = (item or {}).get("status") or {} + return any(c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions", [])) +def pod_summary(item): + status = (item or {}).get("status") or {} + containers = status.get("containerStatuses") or [] + return { + "name": meta_name(item), + "phase": status.get("phase"), + "ready": pod_ready(item), + "restarts": sum(int(c.get("restartCount") or 0) for c in containers if isinstance(c, dict)), + } +def service_summary(item): + spec = (item or {}).get("spec") or {} + return { + "name": meta_name(item), + "type": spec.get("type"), + "clusterIP": spec.get("clusterIP"), + "ports": [{"name": p.get("name"), "port": p.get("port"), "targetPort": p.get("targetPort")} for p in spec.get("ports", [])], + } +def endpoint_ready(item): + subsets = ((item or {}).get("subsets") or []) + return any(s.get("addresses") for s in subsets if isinstance(s, dict)) +def pvc_summary(item): + spec = (item or {}).get("spec") or {} + status = (item or {}).get("status") or {} + return { + "name": meta_name(item), + "phase": status.get("phase"), + "storageClassName": spec.get("storageClassName"), + "capacity": (status.get("capacity") or {}).get("storage"), + } +def event_tail(limit=8): + result = [] + for item in items("events")[-limit:]: + result.append({ + "type": item.get("type"), + "reason": item.get("reason"), + "object": f"{((item.get('involvedObject') or {}).get('kind') or '-')}/{((item.get('involvedObject') or {}).get('name') or '-')}", + "message": (item.get("message") or "")[:300], + "lastTimestamp": item.get("lastTimestamp") or item.get("eventTime"), + }) + return result +sts = load("statefulset") or {} +sts_spec = sts.get("spec") or {} +sts_status = sts.get("status") or {} +desired = int(sts_spec.get("replicas") or 0) +ready_replicas = int(sts_status.get("readyReplicas") or 0) +pods = [pod_summary(item) for item in items("pods")] +service = service_summary(load("service") or {}) +endpoint_ok = endpoint_ready(load("endpoints") or {}) +ready = rc("namespace") == 0 and rc("networkpolicy") == 0 and rc("service") == 0 and desired > 0 and ready_replicas >= desired and endpoint_ok +payload = { + "ok": True, + "ready": ready, + "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), + "route": os.environ.get("UNIDESK_GITEA_ROUTE"), + "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), + "image": os.environ.get("UNIDESK_GITEA_IMAGE"), + "serviceDns": f"{os.environ.get('UNIDESK_GITEA_SERVICE_NAME')}.{os.environ.get('UNIDESK_GITEA_NAMESPACE')}.svc.cluster.local:{os.environ.get('UNIDESK_GITEA_HTTP_PORT')}", + "networkPolicy": {"allowAllPresent": rc("networkpolicy") == 0}, + "statefulSet": {"name": meta_name(sts), "desired": desired, "readyReplicas": ready_replicas, "updatedReplicas": sts_status.get("updatedReplicas")}, + "service": service, + "endpointsReady": endpoint_ok, + "pods": pods, + "pvcs": [pvc_summary(item) for item in items("pvc")], + "eventsTail": event_tail(), + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0) +PY +} + +run_validate() { + proxy_path="/api/v1/namespaces/$UNIDESK_GITEA_NAMESPACE/services/http:$UNIDESK_GITEA_SERVICE_NAME:$UNIDESK_GITEA_HTTP_PORT/proxy$UNIDESK_GITEA_HEALTH_PATH" + kubectl get --raw "$proxy_path" >"$tmp/health.out" 2>"$tmp/health.err" + health_rc=$? + capture_json endpoints kubectl -n "$UNIDESK_GITEA_NAMESPACE" get endpoints "$UNIDESK_GITEA_SERVICE_NAME" + python3 - "$health_rc" "$tmp/health.out" "$tmp/health.err" "$tmp/endpoints.json" "$tmp/endpoints.rc" <<'PY' +import json, os, sys +health_rc = int(sys.argv[1]) +def text(path, limit=2000): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +body = text(sys.argv[2], 4000) +try: + parsed = json.loads(body) +except Exception: + parsed = None +try: + endpoint_rc = int(open(sys.argv[5], encoding="utf-8").read() or "1") +except Exception: + endpoint_rc = 1 +try: + endpoints = json.load(open(sys.argv[4], encoding="utf-8")) +except Exception: + endpoints = {} +subsets = endpoints.get("subsets") if isinstance(endpoints, dict) else [] +endpoints_ready = any(isinstance(s, dict) and s.get("addresses") for s in (subsets or [])) +health_status = parsed.get("status") if isinstance(parsed, dict) else None +ok = health_rc == 0 and endpoint_rc == 0 and endpoints_ready and health_status == "pass" +payload = { + "ok": ok, + "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), + "route": os.environ.get("UNIDESK_GITEA_ROUTE"), + "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), + "service": os.environ.get("UNIDESK_GITEA_SERVICE_NAME"), + "serviceProxyPath": f"/api/v1/namespaces/{os.environ.get('UNIDESK_GITEA_NAMESPACE')}/services/http:{os.environ.get('UNIDESK_GITEA_SERVICE_NAME')}:{os.environ.get('UNIDESK_GITEA_HTTP_PORT')}/proxy{os.environ.get('UNIDESK_GITEA_HEALTH_PATH')}", + "health": { + "exitCode": health_rc, + "status": health_status, + "description": parsed.get("description") if isinstance(parsed, dict) else None, + "bodyBytes": len(body.encode("utf-8")), + "stderrTail": text(sys.argv[3]), + }, + "endpointsReady": endpoints_ready, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if ok else 1) +PY +} + +case "$UNIDESK_GITEA_ACTION" in + apply) run_apply ;; + status) run_status ;; + validate) run_validate ;; + *) printf '{"ok":false,"error":"unsupported-gitea-remote-action"}\n'; exit 2 ;; +esac diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts new file mode 100644 index 00000000..e519d2e6 --- /dev/null +++ b/scripts/src/platform-infra-gitea.ts @@ -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 | 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 { + 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>(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, 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 { + 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> { + 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> { + 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> { + 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 = { + 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 = { + 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> { + 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 { + return { + path: configLabel, + metadata: gitea.metadata, + migration: gitea.migration, + target: targetSummary(target), + app: appSummary(gitea), + valuesPrinted: false, + }; +} + +function compactConfigSummary(gitea: GiteaConfig, target: GiteaTarget): Record { + 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 { + return { + id: target.id, + route: target.route, + namespace: target.namespace, + role: target.role, + createNamespace: target.createNamespace, + storageClassName: target.storageClassName, + }; +} + +function appSummary(gitea: GiteaConfig): Record { + 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): Record { + 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): 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): 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): 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[] { + 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, 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 { + 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> { + const objects: Array> = []; + 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, target: Record): string { + const service = record(app.service); + return `${stringValue(app.serviceName)}.${stringValue(target.namespace)}.svc.cluster.local:${stringValue(service.httpPort)}`; +} + +function positiveInteger(obj: Record, 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, 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, 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] : []; +} + +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)]; +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index f2b3c45a..91779db4 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -369,7 +369,7 @@ export interface ManagedResourceCleanupPlan { export function platformInfraHelp(): unknown { const target = sub2ApiHelpTargetSummary(); return { - command: "platform-infra sub2api|langbot|n8n|wechat-archive|observability|secret-plane|kafka ...", + command: "platform-infra sub2api|langbot|n8n|wechat-archive|observability|secret-plane|kafka|gitea ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", @@ -430,8 +430,13 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra kafka offsets --node D518 --topic hwlab.agentrun.command.v1", "bun scripts/cli.ts platform-infra kafka tail --node D518 --topic hwlab.agentrun.command.v1 --limit 5", "bun scripts/cli.ts platform-infra kafka produce --node D518 --topic hwlab.agentrun.command.v1 --key ", + "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", + "bun scripts/cli.ts platform-infra gitea validate --target JD01", ], - description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, and the D518 Kafka event bus. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.", + description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, and the internal Gitea source-authority service for GH-1548/GH-1549. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.", target, codexPool: { usage: [ diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 310d6b5f..1b4847e9 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -65,6 +65,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const { runPlatformInfraKafkaCommand } = await import("../platform-infra-kafka"); return await runPlatformInfraKafkaCommand(config, args.slice(1)); } + if (target === "gitea") { + const { runPlatformInfraGiteaCommand } = await import("../platform-infra-gitea"); + return await runPlatformInfraGiteaCommand(config, args.slice(1)); + } if (target !== "sub2api") return unsupported(args); if (action === "plan" || action === undefined) { const planArgs = args.slice(2);