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 {
|
function platformInfraHelpSummary(): unknown {
|
||||||
return {
|
return {
|
||||||
command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive ...",
|
command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive|gitea ...",
|
||||||
output: "json",
|
output: "json",
|
||||||
usage: [
|
usage: [
|
||||||
"bun scripts/cli.ts platform-infra sub2api plan",
|
"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 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 wcf-host-status --full",
|
||||||
"bun scripts/cli.ts platform-infra wechat-archive collector-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 {
|
export function platformInfraHelp(): unknown {
|
||||||
const target = sub2ApiHelpTargetSummary();
|
const target = sub2ApiHelpTargetSummary();
|
||||||
return {
|
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",
|
output: "json",
|
||||||
usage: [
|
usage: [
|
||||||
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
|
"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 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 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 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,
|
target,
|
||||||
codexPool: {
|
codexPool: {
|
||||||
usage: [
|
usage: [
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
|
|||||||
const { runPlatformInfraKafkaCommand } = await import("../platform-infra-kafka");
|
const { runPlatformInfraKafkaCommand } = await import("../platform-infra-kafka");
|
||||||
return await runPlatformInfraKafkaCommand(config, args.slice(1));
|
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 (target !== "sub2api") return unsupported(args);
|
||||||
if (action === "plan" || action === undefined) {
|
if (action === "plan" || action === undefined) {
|
||||||
const planArgs = args.slice(2);
|
const planArgs = args.slice(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user