Merge pull request #1898 from pikasTech/feat/sub2rank-platform-infra
feat: 增加 Sub2Rank 受控部署入口
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
version: 1
|
||||
kind: platform-infra-sub2rank
|
||||
|
||||
metadata:
|
||||
id: sub2rank-public
|
||||
owner: unidesk
|
||||
relatedIssues: []
|
||||
|
||||
defaults:
|
||||
targetId: NC01
|
||||
|
||||
application:
|
||||
sourceRoot: /root/sub2rank
|
||||
configRef: /root/sub2rank/config/sub2rank.yaml
|
||||
runtimeTarget: k8s
|
||||
|
||||
image:
|
||||
repository: 127.0.0.1:5000/sub2rank/sub2rank
|
||||
tag: 14f51f5e1242d0e048f51bbea5937e3022361e9a
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
targets:
|
||||
- id: NC01
|
||||
route: NC01:k3s
|
||||
namespace: platform-infra
|
||||
enabled: true
|
||||
replicas: 1
|
||||
publicExposure:
|
||||
enabled: true
|
||||
publicBaseUrl: https://rank.pikapython.com
|
||||
dns:
|
||||
hostname: rank.pikapython.com
|
||||
expectedA: 82.156.23.220
|
||||
resolvers:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
- 223.5.5.5
|
||||
- 114.114.114.114
|
||||
frpc:
|
||||
deploymentName: sub2rank-frpc
|
||||
configMapName: sub2rank-frpc-config
|
||||
configKey: frpc.toml
|
||||
image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
|
||||
serverAddr: 82.156.23.220
|
||||
serverPort: 22000
|
||||
proxyName: platform-infra-sub2rank-nc01-web
|
||||
remotePort: 22095
|
||||
localIP: sub2rank.platform-infra.svc.cluster.local
|
||||
localPort: 8080
|
||||
tokenEnvName: FRP_TOKEN
|
||||
tokenTargetKey: FRP_TOKEN
|
||||
pk01:
|
||||
route: PK01
|
||||
caddyConfigPath: /etc/caddy/Caddyfile
|
||||
caddyServiceName: caddy
|
||||
responseHeaderTimeoutSeconds: 60
|
||||
|
||||
runtime:
|
||||
sharedNetworkPolicyRef:
|
||||
name: allow-all
|
||||
managedBySub2Rank: false
|
||||
service:
|
||||
name: sub2rank
|
||||
port: 8080
|
||||
configMap:
|
||||
name: sub2rank-config
|
||||
key: sub2rank.yaml
|
||||
mountPath: /etc/sub2rank/sub2rank.yaml
|
||||
command:
|
||||
- bun
|
||||
- src/server.ts
|
||||
- --config
|
||||
- /etc/sub2rank/sub2rank.yaml
|
||||
- --runtime
|
||||
- k8s
|
||||
storage:
|
||||
claimName: sub2rank-data
|
||||
size: 1Gi
|
||||
mountPath: /var/lib/sub2rank
|
||||
secret:
|
||||
configRef: config/secrets-distribution.yaml
|
||||
declarationId: sub2rank-runtime
|
||||
requiredTargetKeys:
|
||||
- SUB2RANK_ADMIN_TOKEN
|
||||
- SUB2API_ADMIN_EMAIL
|
||||
- SUB2API_ADMIN_PASSWORD
|
||||
- FRP_TOKEN
|
||||
appEnvTargetKeys:
|
||||
- SUB2RANK_ADMIN_TOKEN
|
||||
- SUB2API_ADMIN_EMAIL
|
||||
- SUB2API_ADMIN_PASSWORD
|
||||
probes:
|
||||
healthPath: /health
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
rollout:
|
||||
fieldManager: unidesk-platform-sub2rank
|
||||
waitTimeoutSeconds: 180
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { applyPk01CaddyManagedBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
|
||||
import { capture, compactCapture, fingerprintValues, parseJsonOutput, redactText, shQuote } from "./platform-infra-ops-library";
|
||||
@@ -48,6 +50,24 @@ export interface FrpcSecretMaterial {
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
export interface FrpcTokenSecretReference {
|
||||
configMapName: string;
|
||||
configKey: string;
|
||||
tokenSecretName: string;
|
||||
tokenSecretKey: string;
|
||||
tokenEnvName: string;
|
||||
}
|
||||
|
||||
export interface PublicServiceRuntimeResources {
|
||||
appDeploymentName: string;
|
||||
serviceName: string;
|
||||
persistentVolumeClaimName: string;
|
||||
configMapName: string;
|
||||
secretName: string;
|
||||
requiredSecretKeys: string[];
|
||||
sharedNetworkPolicyName?: string;
|
||||
}
|
||||
|
||||
export async function applyPk01CaddyBlock(
|
||||
config: UniDeskConfig,
|
||||
serviceId: string,
|
||||
@@ -69,20 +89,7 @@ export function prepareFrpcSecret(params: {
|
||||
const sourcePath = `${params.secretRoot.replace(/\/+$/u, "")}/${exposure.frpc.tokenSourceRef}`;
|
||||
const values = params.parseEnvFile(params.readTextFile(sourcePath));
|
||||
const token = params.requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
|
||||
const frpcToml = [
|
||||
`serverAddr = "${exposure.frpc.serverAddr}"`,
|
||||
`serverPort = ${exposure.frpc.serverPort}`,
|
||||
"loginFailExit = true",
|
||||
`auth.token = "${escapeTomlString(token)}"`,
|
||||
"",
|
||||
"[[proxies]]",
|
||||
`name = "${exposure.frpc.proxyName}"`,
|
||||
'type = "tcp"',
|
||||
`localIP = "${exposure.frpc.localIP}"`,
|
||||
`localPort = ${exposure.frpc.localPort}`,
|
||||
`remotePort = ${exposure.frpc.remotePort}`,
|
||||
"",
|
||||
].join("\n");
|
||||
const frpcToml = renderFrpcToml(exposure, escapeTomlString(token));
|
||||
return {
|
||||
sourceRef: exposure.frpc.tokenSourceRef,
|
||||
sourcePath: params.sourcePathRedactor(sourcePath),
|
||||
@@ -94,6 +101,83 @@ export function prepareFrpcSecret(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function renderEnvSecretFrpcManifest(target: PublicServiceTarget, secret: FrpcTokenSecretReference): string {
|
||||
const exposure = target.publicExposure;
|
||||
if (!exposure.enabled) return "";
|
||||
const frpcToml = renderFrpcToml(exposure, `{{ .Envs.${secret.tokenEnvName} }}`);
|
||||
const configFingerprint = createHash("sha256").update(frpcToml).digest("hex");
|
||||
return `---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${secret.configMapName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
data:
|
||||
${secret.configKey}: |
|
||||
${indentYamlBlock(frpcToml, 4)}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${exposure.frpc.deploymentName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
unidesk.ai/runtime-node: ${target.id}
|
||||
unidesk.ai/public-hostname: ${exposure.dns.hostname}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
||||
app.kubernetes.io/component: tunnel
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
|
||||
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
|
||||
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
|
||||
unidesk.ai/frpc-config-sha256: "${configFingerprint}"
|
||||
spec:
|
||||
containers:
|
||||
- name: frpc
|
||||
image: ${exposure.frpc.image}
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -c
|
||||
- /etc/frp/frpc.toml
|
||||
env:
|
||||
- name: ${secret.tokenEnvName}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${secret.tokenSecretName}
|
||||
key: ${secret.tokenSecretKey}
|
||||
volumeMounts:
|
||||
- name: frpc-config
|
||||
mountPath: /etc/frp/frpc.toml
|
||||
subPath: ${secret.configKey}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: frpc-config
|
||||
configMap:
|
||||
name: ${secret.configMapName}
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderFrpcManifest(target: PublicServiceTarget): string {
|
||||
const exposure = target.publicExposure;
|
||||
if (!exposure.enabled) return "";
|
||||
@@ -146,15 +230,21 @@ spec:
|
||||
`;
|
||||
}
|
||||
|
||||
export function publicServicePolicyChecks(yaml: string, target: PublicServiceTarget, serviceName: string): Array<Record<string, unknown>> {
|
||||
return [
|
||||
export function publicServicePolicyChecks(
|
||||
yaml: string,
|
||||
target: PublicServiceTarget,
|
||||
serviceName: string,
|
||||
options: { requireAllowAllManifest?: boolean } = {},
|
||||
): Array<Record<string, unknown>> {
|
||||
const checks: Array<Record<string, unknown>> = [
|
||||
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: `${serviceName} public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress.` },
|
||||
{ name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." },
|
||||
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
|
||||
{ name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." },
|
||||
{ name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." },
|
||||
{ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` },
|
||||
];
|
||||
if (options.requireAllowAllManifest ?? true) checks.push({ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` });
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function dryRunManifestScript(params: { yaml: string; target: PublicServiceTarget; fieldManager: string; manifestName: string }): string {
|
||||
@@ -225,6 +315,216 @@ export function publicHttpProbe(baseUrl: string, path: string, options: { header
|
||||
};
|
||||
}
|
||||
|
||||
export async function publicDnsProbe(dns: PublicServiceExposure["dns"]): Promise<Record<string, unknown>> {
|
||||
const observations = await Promise.all(dns.resolvers.map(async (resolverAddress) => {
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers([resolverAddress]);
|
||||
try {
|
||||
const addresses = [...new Set(await resolver.resolve4(dns.hostname))].sort();
|
||||
return { resolver: resolverAddress, ok: addresses.includes(dns.expectedA), addresses, error: null };
|
||||
} catch (error) {
|
||||
return { resolver: resolverAddress, ok: false, addresses: [], error: redactText(error instanceof Error ? error.message : String(error)).slice(0, 500) };
|
||||
}
|
||||
}));
|
||||
return {
|
||||
ok: observations.length > 0 && observations.every((item) => item.ok),
|
||||
hostname: dns.hostname,
|
||||
expectedA: dns.expectedA,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyManifestWithExistingSecretScript(params: {
|
||||
yaml: string;
|
||||
target: PublicServiceTarget;
|
||||
fieldManager: string;
|
||||
manifestName: string;
|
||||
secretName: string;
|
||||
requiredSecretKeys: string[];
|
||||
deploymentNames: string[];
|
||||
waitTimeoutSeconds: number;
|
||||
}): string {
|
||||
const encoded = Buffer.from(params.yaml, "utf8").toString("base64");
|
||||
const requiredSecretKeys = JSON.stringify(params.requiredSecretKeys);
|
||||
const deploymentNames = JSON.stringify(params.deploymentNames);
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$tmp/${params.manifestName}.k8s.yaml"
|
||||
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
||||
kubectl -n ${params.target.namespace} get secret ${params.secretName} -o json >"$tmp/secret.json" 2>"$tmp/secret.err"
|
||||
secret_get_rc=$?
|
||||
python3 - "$tmp/secret.json" "$tmp/secret-check.json" '${requiredSecretKeys}' "$secret_get_rc" <<'PY'
|
||||
import json, sys
|
||||
source_path, result_path, required_json, get_rc_text = sys.argv[1:]
|
||||
required = json.loads(required_json)
|
||||
get_rc = int(get_rc_text)
|
||||
data = {}
|
||||
if get_rc == 0:
|
||||
try:
|
||||
data = json.load(open(source_path, encoding="utf-8")).get("data", {})
|
||||
except Exception:
|
||||
data = {}
|
||||
present = sorted(key for key in required if isinstance(data.get(key), str) and len(data[key]) > 0)
|
||||
missing = sorted(set(required) - set(present))
|
||||
payload = {"ok": get_rc == 0 and not missing, "name": "${params.secretName}", "requiredKeys": sorted(required), "presentKeys": present, "missingKeys": missing, "valuesPrinted": False}
|
||||
open(result_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
secret_check_rc=$?
|
||||
if [ "$secret_check_rc" -eq 0 ]; then
|
||||
kubectl apply --server-side --force-conflicts --field-manager=${params.fieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
||||
apply_rc=$?
|
||||
else
|
||||
: >"$tmp/apply.out"
|
||||
printf '%s\n' 'manifest apply skipped because the declared runtime Secret is missing or incomplete' >"$tmp/apply.err"
|
||||
apply_rc=1
|
||||
fi
|
||||
printf '%s' '${deploymentNames}' >"$tmp/deployments.json"
|
||||
if [ "$apply_rc" -eq 0 ]; then
|
||||
python3 - "$tmp/deployments.json" <<'PY' >"$tmp/deployments.txt"
|
||||
import json, sys
|
||||
for value in json.load(open(sys.argv[1], encoding="utf-8")):
|
||||
print(value)
|
||||
PY
|
||||
rollout_rc=0
|
||||
: >"$tmp/rollout.out"
|
||||
: >"$tmp/rollout.err"
|
||||
while IFS= read -r deployment; do
|
||||
[ -n "$deployment" ] || continue
|
||||
timeout ${params.waitTimeoutSeconds} kubectl -n ${params.target.namespace} rollout status "deployment/$deployment" --timeout=${params.waitTimeoutSeconds}s >>"$tmp/rollout.out" 2>>"$tmp/rollout.err" || rollout_rc=$?
|
||||
done <"$tmp/deployments.txt"
|
||||
else
|
||||
rollout_rc=1
|
||||
: >"$tmp/rollout.out"
|
||||
printf '%s\n' 'rollout skipped because manifest apply failed' >"$tmp/rollout.err"
|
||||
fi
|
||||
python3 - "$secret_get_rc" "$secret_check_rc" "$apply_rc" "$rollout_rc" "$tmp/secret-check.json" "$tmp/secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY'
|
||||
import json, sys
|
||||
secret_get_rc, secret_check_rc, apply_rc, rollout_rc = [int(value) for value in sys.argv[1:5]]
|
||||
def text(path, limit):
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
try:
|
||||
secret = json.load(open(sys.argv[5], encoding="utf-8"))
|
||||
except Exception:
|
||||
secret = {"ok": False, "name": "${params.secretName}", "requiredKeys": ${requiredSecretKeys}, "presentKeys": [], "missingKeys": ${requiredSecretKeys}, "valuesPrinted": False}
|
||||
payload = {
|
||||
"ok": secret_check_rc == 0 and apply_rc == 0 and rollout_rc == 0,
|
||||
"target": "${params.target.id}",
|
||||
"namespace": "${params.target.namespace}",
|
||||
"secret": secret,
|
||||
"steps": {
|
||||
"secret": {"exitCode": secret_get_rc, "stderrTail": text(sys.argv[6], 2000)},
|
||||
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[7], 6000), "stderrTail": text(sys.argv[8], 4000)},
|
||||
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[9], 6000), "stderrTail": text(sys.argv[10], 4000)},
|
||||
},
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
export function publicServiceStatusScript(params: {
|
||||
target: PublicServiceTarget;
|
||||
resources: PublicServiceRuntimeResources;
|
||||
healthPath: string;
|
||||
servicePort: number;
|
||||
}): string {
|
||||
const requiredSecretKeys = JSON.stringify(params.resources.requiredSecretKeys);
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
capture_json() {
|
||||
name="$1"
|
||||
shift
|
||||
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
||||
printf '%s' "$?" >"$tmp/$name.rc"
|
||||
}
|
||||
capture_json app kubectl -n ${params.target.namespace} get deployment ${params.resources.appDeploymentName}
|
||||
capture_json frpc kubectl -n ${params.target.namespace} get deployment ${params.target.publicExposure.frpc.deploymentName}
|
||||
capture_json service kubectl -n ${params.target.namespace} get service ${params.resources.serviceName}
|
||||
capture_json pvc kubectl -n ${params.target.namespace} get pvc ${params.resources.persistentVolumeClaimName}
|
||||
capture_json configmap kubectl -n ${params.target.namespace} get configmap ${params.resources.configMapName}
|
||||
capture_json secret kubectl -n ${params.target.namespace} get secret ${params.resources.secretName}
|
||||
capture_json endpoints kubectl -n ${params.target.namespace} get endpoints ${params.resources.serviceName}
|
||||
${params.resources.sharedNetworkPolicyName === undefined ? "" : `capture_json networkpolicy kubectl -n ${params.target.namespace} get networkpolicy ${params.resources.sharedNetworkPolicyName}`}
|
||||
python3 - "$tmp" '${requiredSecretKeys}' <<'PY'
|
||||
import json, os, sys
|
||||
tmp, required_json = sys.argv[1:]
|
||||
required = json.loads(required_json)
|
||||
def rc(name):
|
||||
try:
|
||||
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
||||
except Exception:
|
||||
return 1
|
||||
def load(name):
|
||||
try:
|
||||
return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
def deployment(name):
|
||||
item = load(name) or {}
|
||||
spec, status = item.get("spec", {}), item.get("status", {})
|
||||
desired = int(spec.get("replicas", 0) or 0)
|
||||
ready = int(status.get("readyReplicas", 0) or 0)
|
||||
return {"present": rc(name) == 0, "desired": desired, "ready": ready, "available": int(status.get("availableReplicas", 0) or 0), "ok": rc(name) == 0 and desired > 0 and ready == desired}
|
||||
secret_item = load("secret") or {}
|
||||
secret_data = secret_item.get("data", {}) if isinstance(secret_item.get("data", {}), dict) else {}
|
||||
present_keys = sorted(key for key in required if isinstance(secret_data.get(key), str) and len(secret_data[key]) > 0)
|
||||
missing_keys = sorted(set(required) - set(present_keys))
|
||||
pvc = load("pvc") or {}
|
||||
endpoints = load("endpoints") or {}
|
||||
addresses = sum(len(subset.get("addresses", []) or []) for subset in endpoints.get("subsets", []) or [])
|
||||
app = deployment("app")
|
||||
frpc = deployment("frpc")
|
||||
payload = {
|
||||
"ok": app["ok"] and frpc["ok"] and rc("service") == 0 and rc("pvc") == 0 and pvc.get("status", {}).get("phase") == "Bound" and rc("configmap") == 0 and rc("secret") == 0 and not missing_keys and addresses > 0${params.resources.sharedNetworkPolicyName === undefined ? "" : ' and rc("networkpolicy") == 0'},
|
||||
"target": "${params.target.id}",
|
||||
"namespace": "${params.target.namespace}",
|
||||
"deployments": {"app": app, "frpc": frpc},
|
||||
"service": {"name": "${params.resources.serviceName}", "present": rc("service") == 0, "endpointAddresses": addresses},
|
||||
"persistentVolumeClaim": {"name": "${params.resources.persistentVolumeClaimName}", "present": rc("pvc") == 0, "phase": pvc.get("status", {}).get("phase")},
|
||||
"configMap": {"name": "${params.resources.configMapName}", "present": rc("configmap") == 0},
|
||||
"secret": {"name": "${params.resources.secretName}", "present": rc("secret") == 0, "requiredKeys": sorted(required), "presentKeys": present_keys, "missingKeys": missing_keys, "valuesPrinted": False},
|
||||
"sharedNetworkPolicy": {"name": ${params.resources.sharedNetworkPolicyName === undefined ? "None" : JSON.stringify(params.resources.sharedNetworkPolicyName)}, "present": ${params.resources.sharedNetworkPolicyName === undefined ? "None" : 'rc("networkpolicy") == 0'}, "managedByService": False},
|
||||
"health": {"path": "${params.healthPath}", "source": "deployment-readiness", "probed": False},
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFrpcToml(exposure: PublicServiceExposure, token: string): string {
|
||||
return [
|
||||
`serverAddr = "${exposure.frpc.serverAddr}"`,
|
||||
`serverPort = ${exposure.frpc.serverPort}`,
|
||||
"loginFailExit = true",
|
||||
`auth.token = "${token}"`,
|
||||
"",
|
||||
"[[proxies]]",
|
||||
`name = "${exposure.frpc.proxyName}"`,
|
||||
'type = "tcp"',
|
||||
`localIP = "${exposure.frpc.localIP}"`,
|
||||
`localPort = ${exposure.frpc.localPort}`,
|
||||
`remotePort = ${exposure.frpc.remotePort}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function indentYamlBlock(value: string, spaces: number): string {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return value.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
||||
}
|
||||
|
||||
export function escapeTomlString(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
import { rootPath } from "./config";
|
||||
import type { PublicServiceExposure, PublicServiceTarget } from "./platform-infra-public-service";
|
||||
import { assertNoDuplicateYamlMappingKeys, createYamlFieldReader, readYamlRecord, resolveRepoPath } from "./platform-infra-ops-library";
|
||||
|
||||
export const sub2RankConfigPath = rootPath("config", "platform-infra", "sub2rank.yaml");
|
||||
export const sub2RankConfigLabel = "config/platform-infra/sub2rank.yaml";
|
||||
|
||||
const y = createYamlFieldReader(sub2RankConfigLabel);
|
||||
|
||||
export interface Sub2RankConfig {
|
||||
version: number;
|
||||
kind: "platform-infra-sub2rank";
|
||||
metadata: { id: string; owner: string; relatedIssues: number[] };
|
||||
defaults: { targetId: string };
|
||||
application: {
|
||||
sourceRoot: string;
|
||||
configRef: string;
|
||||
runtimeTarget: string;
|
||||
configText: string;
|
||||
configSha256: string;
|
||||
sourceCommit: string;
|
||||
sourceClean: boolean;
|
||||
sourceRemotePresent: boolean;
|
||||
configMatchesSourceCommit: boolean;
|
||||
deploymentBlockPresent: boolean;
|
||||
automaticCreditEnabled: boolean;
|
||||
server: { listenPort: number; databasePath: string; envKeys: string[] };
|
||||
};
|
||||
image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" };
|
||||
targets: Sub2RankTarget[];
|
||||
runtime: {
|
||||
sharedNetworkPolicyRef: { name: string; managedBySub2Rank: false };
|
||||
service: { name: string; port: number };
|
||||
configMap: { name: string; key: string; mountPath: string };
|
||||
command: string[];
|
||||
storage: { claimName: string; size: string; mountPath: string };
|
||||
secret: ResolvedSecretDeclaration & { configRef: string; declarationId: string; requiredTargetKeys: string[]; appEnvTargetKeys: string[] };
|
||||
probes: { healthPath: string; initialDelaySeconds: number; periodSeconds: number; timeoutSeconds: number; failureThreshold: number };
|
||||
rollout: { fieldManager: string; waitTimeoutSeconds: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface Sub2RankTarget extends PublicServiceTarget {
|
||||
enabled: boolean;
|
||||
publicExposure: PublicServiceExposure & {
|
||||
frpc: PublicServiceExposure["frpc"] & {
|
||||
configMapName: string;
|
||||
configKey: string;
|
||||
tokenEnvName: string;
|
||||
tokenTargetKey: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
namespace: string;
|
||||
enabled: boolean;
|
||||
replicas: number;
|
||||
exposure: Omit<Sub2RankTarget["publicExposure"], "frpc"> & {
|
||||
frpc: Omit<Sub2RankTarget["publicExposure"]["frpc"], "secretName" | "secretKey" | "tokenSourceRef" | "tokenSourceKey">;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedSecretDeclaration {
|
||||
targetId: string;
|
||||
route: string;
|
||||
namespace: string;
|
||||
secretName: string;
|
||||
mappings: Array<{ sourceRef: string; sourceKey: string; targetKey: string }>;
|
||||
}
|
||||
|
||||
export function readSub2RankConfig(): Sub2RankConfig {
|
||||
const root = readYamlRecord<Record<string, unknown>>(sub2RankConfigPath, "platform-infra-sub2rank");
|
||||
const version = y.integerField(root, "version", "");
|
||||
if (version !== 1) throw new Error(`${sub2RankConfigLabel}.version must be 1`);
|
||||
const metadataRecord = y.objectField(root, "metadata", "");
|
||||
const defaultsRecord = y.objectField(root, "defaults", "");
|
||||
const applicationRecord = y.objectField(root, "application", "");
|
||||
const imageRecord = y.objectField(root, "image", "");
|
||||
const runtimeRecord = y.objectField(root, "runtime", "");
|
||||
const defaults = { targetId: simpleId(y.stringField(defaultsRecord, "targetId", "defaults"), "defaults.targetId") };
|
||||
const application = parseApplication(applicationRecord);
|
||||
const image = parseImage(imageRecord);
|
||||
const runtimeBase = parseRuntime(runtimeRecord);
|
||||
const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId);
|
||||
const targets = parseTargets(root.targets, secret);
|
||||
if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`);
|
||||
validateResolvedConfig(application, image, runtimeBase, secret, targets);
|
||||
return {
|
||||
version,
|
||||
kind: "platform-infra-sub2rank",
|
||||
metadata: {
|
||||
id: y.stringField(metadataRecord, "id", "metadata"),
|
||||
owner: y.stringField(metadataRecord, "owner", "metadata"),
|
||||
relatedIssues: y.numberArrayField(metadataRecord, "relatedIssues", "metadata"),
|
||||
},
|
||||
defaults,
|
||||
application,
|
||||
image,
|
||||
targets,
|
||||
runtime: { ...runtimeBase, secret: { ...runtimeBase.secret, ...secret } },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string | null): Sub2RankTarget {
|
||||
const selected = targetId ?? config.defaults.targetId;
|
||||
const target = config.targets.find((item) => item.id === selected);
|
||||
if (target === undefined) throw new Error(`unknown Sub2Rank target ${selected}; known targets: ${config.targets.map((item) => item.id).join(", ")}`);
|
||||
if (!target.enabled) throw new Error(`Sub2Rank target ${selected} is disabled in ${sub2RankConfigLabel}`);
|
||||
return target;
|
||||
}
|
||||
|
||||
function parseApplication(record: Record<string, unknown>): Sub2RankConfig["application"] {
|
||||
const sourceRoot = y.absolutePathField(record, "sourceRoot", "application");
|
||||
const configRef = y.absolutePathField(record, "configRef", "application");
|
||||
const runtimeTarget = simpleId(y.stringField(record, "runtimeTarget", "application"), "application.runtimeTarget");
|
||||
if (!configRef.startsWith(`${sourceRoot.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.application.configRef must be inside application.sourceRoot`);
|
||||
const configText = readFileSync(configRef, "utf8");
|
||||
assertNoDuplicateYamlMappingKeys(configText, configRef);
|
||||
const app = y.asRecord(Bun.YAML.parse(configText), configRef);
|
||||
if (app.kind !== "Sub2Rank") throw new Error(`${configRef}.kind must be Sub2Rank`);
|
||||
const appRuntime = app.runtime;
|
||||
if (typeof appRuntime !== "object" || appRuntime === null || Array.isArray(appRuntime)) throw new Error(`${configRef}.runtime must be an object`);
|
||||
const serverTargets = (appRuntime as Record<string, unknown>).serverTargets;
|
||||
if (typeof serverTargets !== "object" || serverTargets === null || Array.isArray(serverTargets)) throw new Error(`${configRef}.runtime.serverTargets must be an object`);
|
||||
const serverValue = (serverTargets as Record<string, unknown>)[runtimeTarget];
|
||||
if (typeof serverValue !== "object" || serverValue === null || Array.isArray(serverValue)) throw new Error(`${configRef}.runtime.serverTargets.${runtimeTarget} must be an object`);
|
||||
const server = serverValue as Record<string, unknown>;
|
||||
const lottery = app.lottery;
|
||||
if (typeof lottery !== "object" || lottery === null || Array.isArray(lottery)) throw new Error(`${configRef}.lottery must be an object`);
|
||||
const automaticCredit = (lottery as Record<string, unknown>).automaticCredit;
|
||||
if (typeof automaticCredit !== "object" || automaticCredit === null || Array.isArray(automaticCredit)) throw new Error(`${configRef}.lottery.automaticCredit must be an object`);
|
||||
const automaticCreditEnabled = (automaticCredit as Record<string, unknown>).enabled;
|
||||
if (typeof automaticCreditEnabled !== "boolean") throw new Error(`${configRef}.lottery.automaticCredit.enabled must be a boolean`);
|
||||
const sourceCommit = gitText(sourceRoot, ["rev-parse", "HEAD"], "resolve Sub2Rank source commit");
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error(`${sourceRoot} HEAD must resolve to a full Git commit`);
|
||||
const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true);
|
||||
const remote = gitText(sourceRoot, ["remote"], "read Sub2Rank source remotes", true);
|
||||
const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/");
|
||||
const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false);
|
||||
return {
|
||||
sourceRoot,
|
||||
configRef,
|
||||
runtimeTarget,
|
||||
configText,
|
||||
configSha256: createHash("sha256").update(configText).digest("hex"),
|
||||
sourceCommit,
|
||||
sourceClean: statusShort.length === 0,
|
||||
sourceRemotePresent: remote.split(/\r?\n/u).some((value) => value.trim().length > 0),
|
||||
configMatchesSourceCommit: committedConfig === configText.trimEnd(),
|
||||
deploymentBlockPresent: app.deployment !== undefined,
|
||||
automaticCreditEnabled,
|
||||
server: {
|
||||
listenPort: integerValue(server.listenPort, `${configRef}.runtime.serverTargets.${runtimeTarget}.listenPort`),
|
||||
databasePath: absolutePathValue(server.databasePath, `${configRef}.runtime.serverTargets.${runtimeTarget}.databasePath`),
|
||||
envKeys: ["adminTokenEnv", "sub2apiAdminEmailEnv", "sub2apiAdminPasswordEnv"].map((key) => envKeyValue(server[key], `${configRef}.runtime.serverTargets.${runtimeTarget}.${key}`)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseImage(record: Record<string, unknown>): Sub2RankConfig["image"] {
|
||||
const repository = y.stringField(record, "repository", "image");
|
||||
const tag = y.stringField(record, "tag", "image");
|
||||
const pullPolicy = y.enumField(record, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const);
|
||||
if (!/^[A-Za-z0-9._:/-]+$/u.test(repository) || repository.endsWith("/")) throw new Error(`${sub2RankConfigLabel}.image.repository has an unsupported format`);
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${sub2RankConfigLabel}.image.tag has an unsupported format`);
|
||||
return { repository, tag, pullPolicy };
|
||||
}
|
||||
|
||||
function parseRuntime(record: Record<string, unknown>): Omit<Sub2RankConfig["runtime"], "secret"> & {
|
||||
secret: { configRef: string; declarationId: string; requiredTargetKeys: string[]; appEnvTargetKeys: string[] };
|
||||
} {
|
||||
const service = y.objectField(record, "service", "runtime");
|
||||
const sharedNetworkPolicyRef = y.objectField(record, "sharedNetworkPolicyRef", "runtime");
|
||||
const configMap = y.objectField(record, "configMap", "runtime");
|
||||
const storage = y.objectField(record, "storage", "runtime");
|
||||
const secret = y.objectField(record, "secret", "runtime");
|
||||
const probes = y.objectField(record, "probes", "runtime");
|
||||
const rollout = y.objectField(record, "rollout", "runtime");
|
||||
const configRefValue = y.stringField(secret, "configRef", "runtime.secret");
|
||||
return {
|
||||
sharedNetworkPolicyRef: {
|
||||
name: y.kubernetesNameField(sharedNetworkPolicyRef, "name", "runtime.sharedNetworkPolicyRef"),
|
||||
managedBySub2Rank: disabledField(sharedNetworkPolicyRef, "managedBySub2Rank", "runtime.sharedNetworkPolicyRef"),
|
||||
},
|
||||
service: { name: y.kubernetesNameField(service, "name", "runtime.service"), port: y.portField(service, "port", "runtime.service") },
|
||||
configMap: {
|
||||
name: y.kubernetesNameField(configMap, "name", "runtime.configMap"),
|
||||
key: y.stringField(configMap, "key", "runtime.configMap"),
|
||||
mountPath: y.absolutePathField(configMap, "mountPath", "runtime.configMap"),
|
||||
},
|
||||
command: y.stringArrayField(record, "command", "runtime"),
|
||||
storage: {
|
||||
claimName: y.kubernetesNameField(storage, "claimName", "runtime.storage"),
|
||||
size: storageSize(y.stringField(storage, "size", "runtime.storage")),
|
||||
mountPath: y.absolutePathField(storage, "mountPath", "runtime.storage"),
|
||||
},
|
||||
secret: {
|
||||
configRef: configRefValue,
|
||||
declarationId: simpleId(y.stringField(secret, "declarationId", "runtime.secret"), "runtime.secret.declarationId"),
|
||||
requiredTargetKeys: y.stringArrayField(secret, "requiredTargetKeys", "runtime.secret").map((key) => envKeyValue(key, "runtime.secret.requiredTargetKeys")),
|
||||
appEnvTargetKeys: y.stringArrayField(secret, "appEnvTargetKeys", "runtime.secret").map((key) => envKeyValue(key, "runtime.secret.appEnvTargetKeys")),
|
||||
},
|
||||
probes: {
|
||||
healthPath: y.apiPathField(probes, "healthPath", "runtime.probes"),
|
||||
initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "runtime.probes"),
|
||||
periodSeconds: positiveInteger(probes, "periodSeconds", "runtime.probes"),
|
||||
timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "runtime.probes"),
|
||||
failureThreshold: positiveInteger(probes, "failureThreshold", "runtime.probes"),
|
||||
},
|
||||
rollout: {
|
||||
fieldManager: simpleId(y.stringField(rollout, "fieldManager", "runtime.rollout"), "runtime.rollout.fieldManager"),
|
||||
waitTimeoutSeconds: positiveInteger(rollout, "waitTimeoutSeconds", "runtime.rollout"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTargets(value: unknown, secret: ResolvedSecretDeclaration): Sub2RankTarget[] {
|
||||
const records = y.arrayOfRecords(value, "targets");
|
||||
const ids = new Set<string>();
|
||||
return records.map((record, index) => {
|
||||
const path = `targets[${index}]`;
|
||||
const parsed = parseTarget(record, path);
|
||||
if (ids.has(parsed.id)) throw new Error(`${sub2RankConfigLabel}.targets contains duplicate id ${parsed.id}`);
|
||||
ids.add(parsed.id);
|
||||
const mapping = secret.mappings.find((item) => item.targetKey === parsed.exposure.frpc.tokenTargetKey);
|
||||
if (mapping === undefined) throw new Error(`${sub2RankConfigLabel}.${path}.publicExposure.frpc.tokenTargetKey is not provided by runtime.secret.declarationId`);
|
||||
return {
|
||||
id: parsed.id,
|
||||
route: parsed.route,
|
||||
namespace: parsed.namespace,
|
||||
enabled: parsed.enabled,
|
||||
replicas: parsed.replicas,
|
||||
publicExposure: {
|
||||
...parsed.exposure,
|
||||
frpc: {
|
||||
...parsed.exposure.frpc,
|
||||
secretName: secret.secretName,
|
||||
secretKey: mapping.targetKey,
|
||||
tokenSourceRef: mapping.sourceRef,
|
||||
tokenSourceKey: mapping.sourceKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, path: string): ParsedTarget {
|
||||
const exposure = y.objectField(record, "publicExposure", path);
|
||||
const dns = y.objectField(exposure, "dns", `${path}.publicExposure`);
|
||||
const frpc = y.objectField(exposure, "frpc", `${path}.publicExposure`);
|
||||
const pk01 = y.objectField(exposure, "pk01", `${path}.publicExposure`);
|
||||
return {
|
||||
id: simpleId(y.stringField(record, "id", path), `${path}.id`),
|
||||
route: routeValue(y.stringField(record, "route", path), `${path}.route`),
|
||||
namespace: y.kubernetesNameField(record, "namespace", path),
|
||||
enabled: y.booleanField(record, "enabled", path),
|
||||
replicas: positiveInteger(record, "replicas", path),
|
||||
exposure: {
|
||||
enabled: y.booleanField(exposure, "enabled", `${path}.publicExposure`),
|
||||
publicBaseUrl: y.httpsUrlField(exposure, "publicBaseUrl", `${path}.publicExposure`),
|
||||
dns: {
|
||||
hostname: y.hostField(dns, "hostname", `${path}.publicExposure.dns`),
|
||||
expectedA: y.hostField(dns, "expectedA", `${path}.publicExposure.dns`),
|
||||
resolvers: y.stringArrayField(dns, "resolvers", `${path}.publicExposure.dns`),
|
||||
},
|
||||
frpc: {
|
||||
deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.publicExposure.frpc`),
|
||||
configMapName: y.kubernetesNameField(frpc, "configMapName", `${path}.publicExposure.frpc`),
|
||||
configKey: y.stringField(frpc, "configKey", `${path}.publicExposure.frpc`),
|
||||
image: imageValue(y.stringField(frpc, "image", `${path}.publicExposure.frpc`), `${path}.publicExposure.frpc.image`),
|
||||
serverAddr: y.hostField(frpc, "serverAddr", `${path}.publicExposure.frpc`),
|
||||
serverPort: y.portField(frpc, "serverPort", `${path}.publicExposure.frpc`),
|
||||
proxyName: simpleId(y.stringField(frpc, "proxyName", `${path}.publicExposure.frpc`), `${path}.publicExposure.frpc.proxyName`),
|
||||
remotePort: y.portField(frpc, "remotePort", `${path}.publicExposure.frpc`),
|
||||
localIP: y.hostField(frpc, "localIP", `${path}.publicExposure.frpc`),
|
||||
localPort: y.portField(frpc, "localPort", `${path}.publicExposure.frpc`),
|
||||
tokenEnvName: y.envKeyField(frpc, "tokenEnvName", `${path}.publicExposure.frpc`),
|
||||
tokenTargetKey: y.envKeyField(frpc, "tokenTargetKey", `${path}.publicExposure.frpc`),
|
||||
},
|
||||
pk01: {
|
||||
route: routeValue(y.stringField(pk01, "route", `${path}.publicExposure.pk01`), `${path}.publicExposure.pk01.route`),
|
||||
caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`),
|
||||
caddyServiceName: simpleId(y.stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), `${path}.publicExposure.pk01.caddyServiceName`),
|
||||
responseHeaderTimeoutSeconds: positiveInteger(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSecretDeclaration(configRef: string, declarationId: string): ResolvedSecretDeclaration {
|
||||
const path = resolveRepoPath(configRef);
|
||||
const root = readYamlRecord<Record<string, unknown>>(path, "unidesk-secret-distribution");
|
||||
const declarations = Array.isArray(root.kubernetesSecrets) ? root.kubernetesSecrets : [];
|
||||
const declaration = declarations.find((item) => typeof item === "object" && item !== null && !Array.isArray(item) && (item as Record<string, unknown>).name === declarationId);
|
||||
if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) throw new Error(`${configRef} does not contain kubernetesSecrets name=${declarationId}`);
|
||||
const item = declaration as Record<string, unknown>;
|
||||
const targetId = stringValue(item.targetId, `${configRef}.kubernetesSecrets[name=${declarationId}].targetId`);
|
||||
const secretName = kubernetesNameValue(item.secretName, `${configRef}.kubernetesSecrets[name=${declarationId}].secretName`);
|
||||
const targets = Array.isArray(root.targets) ? root.targets : [];
|
||||
const target = targets.find((value) => typeof value === "object" && value !== null && !Array.isArray(value) && (value as Record<string, unknown>).id === targetId);
|
||||
if (typeof target !== "object" || target === null || Array.isArray(target)) throw new Error(`${configRef} does not contain targets id=${targetId}`);
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
const data = Array.isArray(item.data) ? item.data : [];
|
||||
const mappings = data.map((value, index) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}] must be an object`);
|
||||
const mapping = value as Record<string, unknown>;
|
||||
return {
|
||||
sourceRef: sourceRefValue(mapping.sourceRef, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].sourceRef`),
|
||||
sourceKey: envKeyValue(mapping.sourceKey, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].sourceKey`),
|
||||
targetKey: envKeyValue(mapping.targetKey, `${configRef}.kubernetesSecrets[name=${declarationId}].data[${index}].targetKey`),
|
||||
};
|
||||
});
|
||||
return {
|
||||
targetId,
|
||||
route: routeValue(stringValue(targetRecord.route, `${configRef}.targets[id=${targetId}].route`), `${configRef}.targets[id=${targetId}].route`),
|
||||
namespace: kubernetesNameValue(targetRecord.namespace, `${configRef}.targets[id=${targetId}].namespace`),
|
||||
secretName,
|
||||
mappings,
|
||||
};
|
||||
}
|
||||
|
||||
function validateResolvedConfig(
|
||||
application: Sub2RankConfig["application"],
|
||||
image: Sub2RankConfig["image"],
|
||||
runtime: ReturnType<typeof parseRuntime>,
|
||||
secret: ResolvedSecretDeclaration,
|
||||
targets: Sub2RankTarget[],
|
||||
): void {
|
||||
if (!/^[a-f0-9]{40}$/u.test(image.tag) || image.tag !== application.sourceCommit) throw new Error(`${sub2RankConfigLabel}.image.tag must equal the full application source commit ${application.sourceCommit}`);
|
||||
if (!application.sourceClean) throw new Error(`${sub2RankConfigLabel}.application.sourceRoot must be clean before rendering deployment config`);
|
||||
if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`);
|
||||
const required = new Set(runtime.secret.requiredTargetKeys);
|
||||
const provided = new Set(secret.mappings.map((item) => item.targetKey));
|
||||
const missing = [...required].filter((key) => !provided.has(key));
|
||||
if (missing.length > 0) throw new Error(`${sub2RankConfigLabel}.runtime.secret.requiredTargetKeys missing from ${runtime.secret.configRef} declaration ${runtime.secret.declarationId}: ${missing.join(", ")}`);
|
||||
if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) throw new Error(`${sub2RankConfigLabel}.runtime.secret.appEnvTargetKeys must match application runtime server env keys`);
|
||||
if (application.server.listenPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.runtime.service.port must match application runtime server listenPort`);
|
||||
if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.mountPath must contain the application databasePath`);
|
||||
for (const target of targets) {
|
||||
if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`);
|
||||
if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`);
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(record: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = y.integerField(record, key, path);
|
||||
if (value <= 0) throw new Error(`${sub2RankConfigLabel}.${path}.${key} must be > 0`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function disabledField(record: Record<string, unknown>, key: string, path: string): false {
|
||||
const value = y.booleanField(record, key, path);
|
||||
if (value) throw new Error(`${sub2RankConfigLabel}.${path}.${key} must remain false because the resource is shared and owned outside Sub2Rank`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function storageSize(value: string): string {
|
||||
if (!/^[1-9][0-9]*(?:Mi|Gi|Ti)$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.size must use Mi, Gi, or Ti`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function simpleId(value: string, path: string): string {
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be a simple id`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function routeValue(value: string, path: string): string {
|
||||
if (!/^[A-Za-z0-9:_./-]+$/u.test(value)) throw new Error(`${path} has an unsupported route format`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function imageValue(value: string, path: string): string {
|
||||
if (!/^[A-Za-z0-9._:/-]+:[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be an image reference with tag`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value)) throw new Error(`${path} must be an integer`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function absolutePathValue(value: unknown, path: string): string {
|
||||
const text = stringValue(value, path);
|
||||
if (!text.startsWith("/") || text.includes("..")) throw new Error(`${path} must be an absolute path without ..`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function envKeyValue(value: unknown, path: string): string {
|
||||
const text = stringValue(value, path);
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/u.test(text)) throw new Error(`${path} must be an environment key`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function sourceRefValue(value: unknown, path: string): string {
|
||||
const text = stringValue(value, path);
|
||||
if (text.startsWith("/") || text.includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(text)) throw new Error(`${path} must be a relative sourceRef`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function kubernetesNameValue(value: unknown, path: string): string {
|
||||
const text = stringValue(value, path);
|
||||
if (!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(text)) throw new Error(`${path} must be a Kubernetes name`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameStringSet(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && [...left].sort().every((value, index) => value === [...right].sort()[index]);
|
||||
}
|
||||
|
||||
function gitText(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
label: string,
|
||||
allowEmpty = false,
|
||||
trim = true,
|
||||
): string {
|
||||
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = new TextDecoder().decode(result.stderr).replace(/\s+/gu, " ").trim().slice(0, 500);
|
||||
throw new Error(`${label} failed (exit ${result.exitCode}): ${stderr}`);
|
||||
}
|
||||
const output = new TextDecoder().decode(result.stdout);
|
||||
const value = trim ? output.trim() : output.trimEnd();
|
||||
if (!allowEmpty && value.length === 0) throw new Error(`${label} returned no output`);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
import {
|
||||
applyManifestWithExistingSecretScript,
|
||||
applyPk01CaddyBlock,
|
||||
capture,
|
||||
compactCapture,
|
||||
dryRunManifestScript,
|
||||
parseJsonOutput,
|
||||
publicDnsProbe,
|
||||
publicHttpProbe,
|
||||
publicServicePolicyChecks,
|
||||
publicServiceStatusScript,
|
||||
renderEnvSecretFrpcManifest,
|
||||
type PublicServiceRuntimeResources,
|
||||
} from "./platform-infra-public-service";
|
||||
import { parseOpsApplyOptions, parseOpsCommonOptions, type OpsApplyOptions, type OpsCommonOptions } from "./platform-infra-ops-library";
|
||||
import { readSub2RankConfig, resolveSub2RankTarget, sub2RankConfigLabel, type Sub2RankConfig, type Sub2RankTarget } from "./platform-infra-sub2rank-config";
|
||||
|
||||
const serviceId = "sub2rank";
|
||||
|
||||
export function sub2RankHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra sub2rank plan|apply|status|validate",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]",
|
||||
],
|
||||
configTruth: sub2RankConfigLabel,
|
||||
applicationConfigRef: "/root/sub2rank/config/sub2rank.yaml",
|
||||
artifactPolicy: "Consumes the prebuilt image declared by owning YAML; apply never runs Docker or builds from a host worktree.",
|
||||
secretPolicy: "Resolves the existing Secret declaration from config/secrets-distribution.yaml and never reads or prints runtime Secret values.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1)));
|
||||
if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1)));
|
||||
if (action === "status") return await status(config, parseOpsCommonOptions(args.slice(1)));
|
||||
if (action === "validate") return await validate(config, parseOpsCommonOptions(args.slice(1)));
|
||||
return { ok: false, error: "unsupported-platform-infra-sub2rank-command", args, help: sub2RankHelp() };
|
||||
}
|
||||
|
||||
function plan(options: OpsCommonOptions): Record<string, unknown> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const yaml = renderManifest(sub2rank, target);
|
||||
const policy = policyChecks(sub2rank, target, yaml);
|
||||
return {
|
||||
ok: policy.every((item) => item.ok),
|
||||
action: "platform-infra-sub2rank-plan",
|
||||
mutation: false,
|
||||
config: configSummary(sub2rank, target),
|
||||
policy,
|
||||
artifact: {
|
||||
image: `${sub2rank.image.repository}:${sub2rank.image.tag}`,
|
||||
buildDisposition: "not-performed-by-apply",
|
||||
sourceRoot: sub2rank.application.sourceRoot,
|
||||
sourceCommit: sub2rank.application.sourceCommit,
|
||||
sourceClean: sub2rank.application.sourceClean,
|
||||
sourceRemotePresent: sub2rank.application.sourceRemotePresent,
|
||||
publication: sub2rank.application.sourceRemotePresent
|
||||
? { supported: false, blocker: "remote-source-not-registered-with-controlled-ci", required: "Register the repository as a YAML-owned PaC/Tekton source authority before publishing the image." }
|
||||
: { supported: false, blocker: "remote-source-authority-missing", required: "Create or select an independent Git remote source authority, then register it with the YAML-owned PaC/Tekton producer." },
|
||||
},
|
||||
topology: `client -> PK01 Caddy -> PK01 frps:${target.publicExposure.frpc.remotePort} -> ${target.id} frpc -> ${sub2rank.runtime.service.name}.${target.namespace}.svc.cluster.local:${sub2rank.runtime.service.port}`,
|
||||
next: {
|
||||
secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope sub2rank --confirm",
|
||||
dryRun: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --dry-run`,
|
||||
apply: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --confirm`,
|
||||
status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const yaml = renderManifest(sub2rank, target);
|
||||
const policy = policyChecks(sub2rank, target, yaml);
|
||||
if (!policy.every((item) => item.ok)) {
|
||||
return { ok: false, action: "platform-infra-sub2rank-apply", mode: "policy-blocked", mutation: false, target: targetSummary(target), policy };
|
||||
}
|
||||
if (options.confirm && !options.wait) {
|
||||
const job = startJob(
|
||||
`platform_infra_sub2rank_apply_${target.id.toLowerCase()}`,
|
||||
["bun", "scripts/cli.ts", "platform-infra", "sub2rank", "apply", "--target", target.id, "--confirm", "--wait"],
|
||||
`Apply ${target.id} Sub2Rank manifests and reconcile the YAML-owned PK01 Caddy site through the controlled UniDesk CLI`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
mode: "async-job",
|
||||
mutation: true,
|
||||
target: targetSummary(target),
|
||||
job,
|
||||
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
||||
next: {
|
||||
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
||||
runtime: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const remote = await capture(config, target.route, ["sh"], dryRunManifestScript({
|
||||
yaml,
|
||||
target,
|
||||
fieldManager: sub2rank.runtime.rollout.fieldManager,
|
||||
manifestName: serviceId,
|
||||
}));
|
||||
const parsed = parseJsonOutput(remote.stdout);
|
||||
return {
|
||||
ok: remote.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
mode: "dry-run",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
policy,
|
||||
remote: parsed ?? compactCapture(remote, { full: true }),
|
||||
};
|
||||
}
|
||||
const resources = runtimeResources(sub2rank, target);
|
||||
const remote = await capture(config, target.route, ["sh"], applyManifestWithExistingSecretScript({
|
||||
yaml,
|
||||
target,
|
||||
fieldManager: sub2rank.runtime.rollout.fieldManager,
|
||||
manifestName: serviceId,
|
||||
secretName: resources.secretName,
|
||||
requiredSecretKeys: resources.requiredSecretKeys,
|
||||
deploymentNames: [resources.appDeploymentName, target.publicExposure.frpc.deploymentName],
|
||||
waitTimeoutSeconds: sub2rank.runtime.rollout.waitTimeoutSeconds,
|
||||
}));
|
||||
const parsed = parseJsonOutput(remote.stdout);
|
||||
const remoteOk = remote.exitCode === 0 && parsed?.ok === true;
|
||||
const caddy = remoteOk
|
||||
? await applyPk01CaddyBlock(config, serviceId, target.publicExposure)
|
||||
: { ok: false, mutation: false, disposition: "skipped-runtime-apply-failed" };
|
||||
return {
|
||||
ok: remoteOk && caddy.ok === true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
mode: "confirmed",
|
||||
mutation: true,
|
||||
target: targetSummary(target),
|
||||
policy,
|
||||
secret: secretSummary(sub2rank),
|
||||
remote: parsed ?? compactCapture(remote, { full: true }),
|
||||
pk01Caddy: caddy,
|
||||
next: {
|
||||
status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function status(config: UniDeskConfig, options: OpsCommonOptions): Promise<Record<string, unknown>> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const resources = runtimeResources(sub2rank, target);
|
||||
const remote = await capture(config, target.route, ["sh"], publicServiceStatusScript({
|
||||
target,
|
||||
resources,
|
||||
healthPath: sub2rank.runtime.probes.healthPath,
|
||||
servicePort: sub2rank.runtime.service.port,
|
||||
}));
|
||||
const parsed = parseJsonOutput(remote.stdout);
|
||||
return {
|
||||
ok: remote.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-sub2rank-status",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
configRefs: configRefsSummary(sub2rank),
|
||||
summary: parsed,
|
||||
remote: compactCapture(remote, { full: options.full || remote.exitCode !== 0 }),
|
||||
...(options.raw ? { raw: remote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function validate(config: UniDeskConfig, options: OpsCommonOptions): Promise<Record<string, unknown>> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const runtime = await status(config, options);
|
||||
const dns = await publicDnsProbe(target.publicExposure.dns);
|
||||
const publicHttps = publicHttpProbe(target.publicExposure.publicBaseUrl, sub2rank.runtime.probes.healthPath);
|
||||
return {
|
||||
ok: runtime.ok === true && dns.ok === true && publicHttps.ok === true,
|
||||
action: "platform-infra-sub2rank-validate",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
runtime,
|
||||
dns,
|
||||
publicHttps,
|
||||
automaticCredit: { enabled: sub2rank.application.automaticCreditEnabled, source: sub2rank.application.configRef },
|
||||
};
|
||||
}
|
||||
|
||||
function renderManifest(config: Sub2RankConfig, target: Sub2RankTarget): string {
|
||||
const runtime = config.runtime;
|
||||
const image = `${config.image.repository}:${config.image.tag}`;
|
||||
const configHash = createHash("sha256").update(JSON.stringify({ deployment: config, target, appConfigSha256: config.application.configSha256 })).digest("hex").slice(0, 16);
|
||||
const appEnv = runtime.secret.appEnvTargetKeys.map((key) => ` - name: ${key}\n valueFrom:\n secretKeyRef:\n name: ${runtime.secret.secretName}\n key: ${key}`).join("\n");
|
||||
const command = runtime.command.map((value) => ` - ${yamlQuoted(value)}`).join("\n");
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${runtime.configMap.name}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
data:
|
||||
${runtime.configMap.key}: |
|
||||
${indent(config.application.configText, 4)}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ${runtime.storage.claimName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: ${runtime.storage.size}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${runtime.service.name}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
ports:
|
||||
- name: http
|
||||
port: ${runtime.service.port}
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${runtime.service.name}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
replicas: ${target.replicas}
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/sub2rank-config-sha256: "${config.application.configSha256}"
|
||||
unidesk.ai/sub2rank-deployment-hash: "${configHash}"
|
||||
unidesk.ai/public-base-url: "${target.publicExposure.publicBaseUrl}"
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: ${runtime.service.name}
|
||||
image: ${image}
|
||||
imagePullPolicy: ${config.image.pullPolicy}
|
||||
command:
|
||||
${command}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: ${runtime.service.port}
|
||||
env:
|
||||
${appEnv}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: ${runtime.probes.healthPath}
|
||||
port: http
|
||||
initialDelaySeconds: ${runtime.probes.initialDelaySeconds}
|
||||
periodSeconds: ${runtime.probes.periodSeconds}
|
||||
timeoutSeconds: ${runtime.probes.timeoutSeconds}
|
||||
failureThreshold: ${runtime.probes.failureThreshold}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: ${runtime.probes.healthPath}
|
||||
port: http
|
||||
initialDelaySeconds: ${runtime.probes.initialDelaySeconds}
|
||||
periodSeconds: ${runtime.probes.periodSeconds}
|
||||
timeoutSeconds: ${runtime.probes.timeoutSeconds}
|
||||
failureThreshold: ${runtime.probes.failureThreshold}
|
||||
volumeMounts:
|
||||
- name: app-config
|
||||
mountPath: ${runtime.configMap.mountPath}
|
||||
subPath: ${runtime.configMap.key}
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: ${runtime.storage.mountPath}
|
||||
volumes:
|
||||
- name: app-config
|
||||
configMap:
|
||||
name: ${runtime.configMap.name}
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ${runtime.storage.claimName}
|
||||
${renderEnvSecretFrpcManifest(target, {
|
||||
configMapName: target.publicExposure.frpc.configMapName,
|
||||
configKey: target.publicExposure.frpc.configKey,
|
||||
tokenSecretName: runtime.secret.secretName,
|
||||
tokenSecretKey: target.publicExposure.frpc.tokenTargetKey,
|
||||
tokenEnvName: target.publicExposure.frpc.tokenEnvName,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function policyChecks(config: Sub2RankConfig, target: Sub2RankTarget, yaml: string): Array<{ name: string; ok: boolean; detail: string }> {
|
||||
return [
|
||||
...publicServicePolicyChecks(yaml, target, serviceId, { requireAllowAllManifest: false }).map((item) => ({ name: String(item.name), ok: item.ok === true, detail: String(item.detail) })),
|
||||
{
|
||||
name: "shared-runtime-resources-not-owned",
|
||||
ok: !/^\s*kind:\s*(?:Namespace|NetworkPolicy)\s*$/mu.test(yaml) && config.runtime.sharedNetworkPolicyRef.managedBySub2Rank === false,
|
||||
detail: `Sub2Rank references ${target.namespace}/NetworkPolicy/${config.runtime.sharedNetworkPolicyRef.name} but does not apply or seize field ownership of shared runtime resources.`,
|
||||
},
|
||||
{
|
||||
name: "deployment-owned-by-unidesk",
|
||||
ok: !config.application.deploymentBlockPresent,
|
||||
detail: `Deployment, image, Secret, FRP and Caddy facts belong only to ${sub2RankConfigLabel}; the application config must not contain a deployment block.`,
|
||||
},
|
||||
{
|
||||
name: "existing-secret-source-ref",
|
||||
ok: config.runtime.secret.requiredTargetKeys.every((key) => config.runtime.secret.mappings.some((mapping) => mapping.targetKey === key)),
|
||||
detail: `Runtime keys resolve through ${config.runtime.secret.configRef} declaration ${config.runtime.secret.declarationId}; valuesPrinted=false.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record<string, unknown> {
|
||||
return {
|
||||
configRefs: configRefsSummary(config),
|
||||
target: targetSummary(target),
|
||||
image: `${config.image.repository}:${config.image.tag}`,
|
||||
runtime: {
|
||||
service: `${config.runtime.service.name}.${target.namespace}.svc.cluster.local:${config.runtime.service.port}`,
|
||||
storage: { claimName: config.runtime.storage.claimName, size: config.runtime.storage.size, mountPath: config.runtime.storage.mountPath },
|
||||
secret: secretSummary(config),
|
||||
},
|
||||
publicExposure: {
|
||||
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
||||
expectedA: target.publicExposure.dns.expectedA,
|
||||
remotePort: target.publicExposure.frpc.remotePort,
|
||||
marker: serviceId,
|
||||
},
|
||||
automaticCredit: { enabled: config.application.automaticCreditEnabled, source: config.application.configRef },
|
||||
};
|
||||
}
|
||||
|
||||
function configRefsSummary(config: Sub2RankConfig): Record<string, unknown> {
|
||||
return {
|
||||
deployment: { path: sub2RankConfigLabel, kind: config.kind },
|
||||
application: {
|
||||
path: config.application.configRef,
|
||||
sha256: config.application.configSha256,
|
||||
deploymentBlockPresent: config.application.deploymentBlockPresent,
|
||||
sourceCommit: config.application.sourceCommit,
|
||||
sourceClean: config.application.sourceClean,
|
||||
sourceRemotePresent: config.application.sourceRemotePresent,
|
||||
configMatchesSourceCommit: config.application.configMatchesSourceCommit,
|
||||
},
|
||||
secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId },
|
||||
};
|
||||
}
|
||||
|
||||
function secretSummary(config: Sub2RankConfig): Record<string, unknown> {
|
||||
return {
|
||||
name: config.runtime.secret.secretName,
|
||||
declarationId: config.runtime.secret.declarationId,
|
||||
mappings: config.runtime.secret.mappings.map((item) => ({ sourceRef: item.sourceRef, sourceKey: item.sourceKey, targetKey: item.targetKey })),
|
||||
requiredTargetKeys: config.runtime.secret.requiredTargetKeys,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function targetSummary(target: Sub2RankTarget): Record<string, unknown> {
|
||||
return {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
replicas: target.replicas,
|
||||
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeResources(config: Sub2RankConfig, target: Sub2RankTarget): PublicServiceRuntimeResources {
|
||||
return {
|
||||
appDeploymentName: config.runtime.service.name,
|
||||
serviceName: config.runtime.service.name,
|
||||
persistentVolumeClaimName: config.runtime.storage.claimName,
|
||||
configMapName: config.runtime.configMap.name,
|
||||
secretName: config.runtime.secret.secretName,
|
||||
requiredSecretKeys: config.runtime.secret.requiredTargetKeys,
|
||||
sharedNetworkPolicyName: config.runtime.sharedNetworkPolicyRef.name,
|
||||
};
|
||||
}
|
||||
|
||||
function indent(value: string, spaces: number): string {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return value.trimEnd().split("\n").map((line) => `${prefix}${line}`).join("\n");
|
||||
}
|
||||
|
||||
function yamlQuoted(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -391,7 +391,7 @@ export interface ManagedResourceCleanupPlan {
|
||||
export function platformInfraHelp(): unknown {
|
||||
const target = sub2ApiHelpTargetSummary();
|
||||
return {
|
||||
command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...",
|
||||
command: "platform-infra sub2api|sub2rank|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
|
||||
@@ -406,6 +406,11 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra egress-proxy traffic --target D601 --sample-seconds 15",
|
||||
"bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark --targets D601,D518 --profile no-mirror-600m --dry-run",
|
||||
"bun scripts/cli.ts platform-infra langbot plan [--target G14]",
|
||||
|
||||
@@ -38,6 +38,10 @@ export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
|
||||
|
||||
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [target, action] = args;
|
||||
if (target === "sub2rank") {
|
||||
const { runSub2RankCommand } = await import("../platform-infra-sub2rank");
|
||||
return await runSub2RankCommand(config, args.slice(1));
|
||||
}
|
||||
if (target === "egress-proxy") {
|
||||
const { runPlatformInfraEgressProxyCommand } = await import("../platform-infra-egress-proxy");
|
||||
return await runPlatformInfraEgressProxyCommand(config, args.slice(1));
|
||||
|
||||
Reference in New Issue
Block a user