feat: add platform gitea install entry
This commit is contained in:
@@ -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
|
||||
+6
-2
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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 <trace-or-session>",
|
||||
"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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user