824 lines
31 KiB
TypeScript
824 lines
31 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
|
|
|
const g14K3sRoute = "G14:k3s";
|
|
const namespace = "platform-infra";
|
|
const serviceName = "sub2api";
|
|
const fieldManager = "unidesk-platform-infra";
|
|
const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml");
|
|
const configPath = rootPath("config", "platform-infra", "sub2api.yaml");
|
|
const secretName = "sub2api-secrets";
|
|
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
|
|
|
|
interface Sub2ApiConfig {
|
|
image: {
|
|
repository: string;
|
|
tag: string;
|
|
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
};
|
|
}
|
|
|
|
export function platformInfraHelp(): unknown {
|
|
return {
|
|
command: "platform-infra sub2api plan|apply|status|validate|codex-pool",
|
|
output: "json",
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra sub2api plan",
|
|
"bun scripts/cli.ts platform-infra sub2api apply --dry-run",
|
|
"bun scripts/cli.ts platform-infra sub2api apply --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api status [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra sub2api validate [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
|
],
|
|
description: "Operate the G14 k3s internal-only Sub2API deployment in the shared platform-infra namespace. This entry creates no Ingress, NodePort, LoadBalancer, hostPort, hostNetwork, ResourceQuota, LimitRange, or CPU/memory resource requests/limits.",
|
|
target: {
|
|
route: g14K3sRoute,
|
|
namespace,
|
|
service: serviceName,
|
|
serviceDns: `${serviceName}.${namespace}.svc.cluster.local:8080`,
|
|
exposure: "k3s-cluster-internal-only",
|
|
resourceLimits: "unset-by-policy",
|
|
versionConfigPath: configPath,
|
|
},
|
|
codexPool: {
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
|
],
|
|
module: "scripts/src/platform-infra-sub2api-codex.ts",
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const [target, action] = args;
|
|
if (target !== "sub2api") return unsupported(args);
|
|
if (action === "plan" || action === undefined) return plan();
|
|
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));
|
|
if (action === "status") return await status(config, parseDisclosureOptions(args.slice(2)));
|
|
if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2)));
|
|
if (action === "codex-pool") {
|
|
const { runCodexPoolCommand } = await import("./platform-infra-sub2api-codex");
|
|
return await runCodexPoolCommand(config, args.slice(2));
|
|
}
|
|
return unsupported(args);
|
|
}
|
|
|
|
interface ApplyOptions {
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
interface DisclosureOptions {
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface PolicyCheck {
|
|
name: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
function unsupported(args: string[]): Record<string, unknown> {
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-command",
|
|
args,
|
|
help: platformInfraHelp(),
|
|
};
|
|
}
|
|
|
|
function parseApplyOptions(args: string[]): ApplyOptions {
|
|
validateOptions(args, new Set(["--dry-run", "--confirm", "--wait"]));
|
|
if (args.includes("--dry-run") && args.includes("--confirm")) throw new Error("apply accepts only one of --dry-run or --confirm");
|
|
return {
|
|
dryRun: args.includes("--dry-run") || !args.includes("--confirm"),
|
|
confirm: args.includes("--confirm"),
|
|
wait: args.includes("--wait"),
|
|
};
|
|
}
|
|
|
|
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
|
validateOptions(args, new Set(["--full", "--raw"]));
|
|
const raw = args.includes("--raw");
|
|
return { full: raw || args.includes("--full"), raw };
|
|
}
|
|
|
|
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
|
for (const arg of args) {
|
|
if (booleanOptions.has(arg)) continue;
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
}
|
|
|
|
function readSub2ApiConfig(): Sub2ApiConfig {
|
|
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configPath} must contain a YAML object`);
|
|
const image = (parsed as { image?: unknown }).image;
|
|
if (typeof image !== "object" || image === null || Array.isArray(image)) throw new Error(`${configPath}.image must be an object`);
|
|
const record = image as Record<string, unknown>;
|
|
const repository = stringField(record, "repository", "image");
|
|
const tag = stringField(record, "tag", "image");
|
|
const pullPolicy = stringField(record, "pullPolicy", "image");
|
|
if (pullPolicy !== "Always" && pullPolicy !== "IfNotPresent" && pullPolicy !== "Never") throw new Error(`${configPath}.image.pullPolicy must be Always, IfNotPresent, or Never`);
|
|
if (!/^[a-z0-9._/-]+(?::[0-9]+)?$/u.test(repository)) throw new Error(`${configPath}.image.repository has an unsupported format`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.image.tag has an unsupported format`);
|
|
return { image: { repository, tag, pullPolicy } };
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${configPath}.${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function imageRef(sub2api: Sub2ApiConfig): string {
|
|
return `${sub2api.image.repository}:${sub2api.image.tag}`;
|
|
}
|
|
|
|
function manifest(): string {
|
|
const sub2api = readSub2ApiConfig();
|
|
const template = readFileSync(manifestPath, "utf8");
|
|
return template
|
|
.replaceAll("__SUB2API_IMAGE__", imageRef(sub2api))
|
|
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", sub2api.image.pullPolicy);
|
|
}
|
|
|
|
function plan(): Record<string, unknown> {
|
|
const sub2api = readSub2ApiConfig();
|
|
const yaml = manifest();
|
|
const policy = policyChecks(yaml);
|
|
return {
|
|
ok: policy.every((check) => check.ok),
|
|
action: "platform-infra-sub2api-plan",
|
|
target: {
|
|
route: g14K3sRoute,
|
|
namespace,
|
|
manifestPath,
|
|
configPath,
|
|
fieldManager,
|
|
serviceDns: `${serviceName}.${namespace}.svc.cluster.local:8080`,
|
|
},
|
|
config: {
|
|
image: imageRef(sub2api),
|
|
pullPolicy: sub2api.image.pullPolicy,
|
|
},
|
|
decision: {
|
|
owner: "UniDesk",
|
|
namespace,
|
|
reason: "Sub2API is an internal shared platform utility for G14 k3s workloads, so it belongs with platform infrastructure rather than a user workload namespace.",
|
|
exposure: "ClusterIP only; no public ingress or node-level exposure.",
|
|
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
|
|
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
|
|
dataStores: ["PostgreSQL 18", "Redis 8"],
|
|
appPoolCaps: {
|
|
databaseMaxOpenConns: 10,
|
|
databaseMaxIdleConns: 2,
|
|
redisPoolSize: 32,
|
|
redisMinIdleConns: 2,
|
|
},
|
|
},
|
|
policy,
|
|
next: {
|
|
dryRun: "bun scripts/cli.ts platform-infra sub2api apply --dry-run",
|
|
apply: "bun scripts/cli.ts platform-infra sub2api apply --confirm",
|
|
status: "bun scripts/cli.ts platform-infra sub2api status",
|
|
validate: "bun scripts/cli.ts platform-infra sub2api validate",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const yaml = manifest();
|
|
const policy = policyChecks(yaml);
|
|
if (!policy.every((check) => check.ok)) {
|
|
return {
|
|
ok: false,
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "policy-blocked",
|
|
policy,
|
|
};
|
|
}
|
|
if (options.confirm && !options.wait) {
|
|
const job = startJob(
|
|
"platform_infra_sub2api_apply",
|
|
["bun", "scripts/cli.ts", "platform-infra", "sub2api", "apply", "--confirm", "--wait"],
|
|
"Apply G14 k3s platform-infra Sub2API manifests through the controlled UniDesk CLI",
|
|
);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "async-job",
|
|
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`,
|
|
rollout: "bun scripts/cli.ts platform-infra sub2api status",
|
|
validate: "bun scripts/cli.ts platform-infra sub2api validate",
|
|
},
|
|
};
|
|
}
|
|
if (options.dryRun) {
|
|
const result = await capture(config, g14K3sRoute, ["script"], dryRunScript(yaml));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "dry-run",
|
|
policy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
};
|
|
}
|
|
const result = await capture(config, g14K3sRoute, ["script"], applyScript(yaml));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "confirmed",
|
|
policy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: {
|
|
status: "bun scripts/cli.ts platform-infra sub2api status",
|
|
validate: "bun scripts/cli.ts platform-infra sub2api validate",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
|
const result = await capture(config, g14K3sRoute, ["script"], statusScript(imageRef(readSub2ApiConfig())));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
if (options.raw) {
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-status",
|
|
remote: compactCapture(result, { full: true }),
|
|
parsed,
|
|
};
|
|
}
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-status",
|
|
summary: parsed,
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
async function validate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
|
const result = await capture(config, g14K3sRoute, ["script"], validateScript());
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
if (options.raw) {
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-validate",
|
|
remote: compactCapture(result, { full: true }),
|
|
parsed,
|
|
};
|
|
}
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-validate",
|
|
summary: parsed,
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
function policyChecks(yaml: string): PolicyCheck[] {
|
|
return [
|
|
{
|
|
name: "no-ingress",
|
|
ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml),
|
|
detail: "Sub2API must not be exposed through Kubernetes Ingress.",
|
|
},
|
|
{
|
|
name: "no-nodeport-or-loadbalancer",
|
|
ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml),
|
|
detail: "Services must remain ClusterIP/internal-only.",
|
|
},
|
|
{
|
|
name: "no-host-network",
|
|
ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml),
|
|
detail: "Pods must not join the 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: "Issue #220 requires no Kubernetes CPU/memory requests or limits.",
|
|
},
|
|
{
|
|
name: "no-resource-quota-or-limit-range",
|
|
ok: !/^\s*kind:\s*(ResourceQuota|LimitRange)\s*$/mu.test(yaml),
|
|
detail: "The platform-infra namespace must not receive quota/default limit objects for this deployment.",
|
|
},
|
|
{
|
|
name: "expected-namespace",
|
|
ok: new RegExp(`^\\s*name:\\s*${namespace}\\s*$`, "mu").test(yaml),
|
|
detail: `Manifest declares namespace ${namespace}.`,
|
|
},
|
|
];
|
|
}
|
|
|
|
function dryRunScript(yaml: string): string {
|
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/sub2api.k8s.yaml"
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
client_out="$tmp/client.out"
|
|
client_err="$tmp/client.err"
|
|
server_out="$tmp/server.out"
|
|
server_err="$tmp/server.err"
|
|
kubectl apply --dry-run=client -f "$manifest" >"$client_out" 2>"$client_err"
|
|
client_rc=$?
|
|
if kubectl get namespace ${namespace} >/dev/null 2>&1; then
|
|
namespace_exists=true
|
|
kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >"$server_out" 2>"$server_err"
|
|
server_rc=$?
|
|
else
|
|
namespace_exists=false
|
|
server_rc=0
|
|
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet; first apply creates it before namespaced resources' >"$server_out"
|
|
: >"$server_err"
|
|
fi
|
|
python3 - "$client_rc" "$server_rc" "$namespace_exists" "$client_out" "$client_err" "$server_out" "$server_err" <<'PY'
|
|
import json
|
|
import sys
|
|
client_rc = int(sys.argv[1])
|
|
server_rc = int(sys.argv[2])
|
|
namespace_exists = sys.argv[3] == "true"
|
|
paths = sys.argv[4:]
|
|
def text(path):
|
|
try:
|
|
return open(path, encoding="utf-8").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
payload = {
|
|
"ok": client_rc == 0 and server_rc == 0,
|
|
"namespace": "${namespace}",
|
|
"namespaceExistsBeforeDryRun": namespace_exists,
|
|
"clientDryRun": {
|
|
"exitCode": client_rc,
|
|
"stdout": text(paths[0])[-4000:],
|
|
"stderr": text(paths[1])[-4000:],
|
|
},
|
|
"serverDryRun": {
|
|
"exitCode": server_rc,
|
|
"disposition": "executed" if namespace_exists else "skipped-namespace-missing",
|
|
"stdout": text(paths[2])[-4000:],
|
|
"stderr": text(paths[3])[-4000:],
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function applyScript(yaml: string): string {
|
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/sub2api.k8s.yaml"
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
ns_out="$tmp/ns.out"
|
|
ns_err="$tmp/ns.err"
|
|
secret_out="$tmp/secret.out"
|
|
secret_err="$tmp/secret.err"
|
|
apply_out="$tmp/apply.out"
|
|
apply_err="$tmp/apply.err"
|
|
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
|
|
ns_rc=$?
|
|
secret_action="unknown"
|
|
secret_rc=0
|
|
if [ "$ns_rc" -eq 0 ]; then
|
|
if kubectl -n ${namespace} get secret ${secretName} >/dev/null 2>&1; then
|
|
secret_action="kept-existing"
|
|
: >"$secret_out"
|
|
: >"$secret_err"
|
|
else
|
|
rand_hex() {
|
|
bytes="$1"
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
openssl rand -hex "$bytes"
|
|
else
|
|
dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n'
|
|
fi
|
|
}
|
|
kubectl -n ${namespace} create secret generic ${secretName} \\
|
|
--from-literal=POSTGRES_PASSWORD="$(rand_hex 32)" \\
|
|
--from-literal=ADMIN_PASSWORD="$(rand_hex 16)" \\
|
|
--from-literal=JWT_SECRET="$(rand_hex 32)" \\
|
|
--from-literal=TOTP_ENCRYPTION_KEY="$(rand_hex 32)" \\
|
|
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err"
|
|
secret_rc=$?
|
|
secret_action="created"
|
|
fi
|
|
fi
|
|
apply_rc=1
|
|
if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ]; then
|
|
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
|
|
apply_rc=$?
|
|
else
|
|
: >"$apply_out"
|
|
printf '%s\\n' 'skipped because namespace or secret step failed' >"$apply_err"
|
|
fi
|
|
python3 - "$ns_rc" "$secret_rc" "$apply_rc" "$secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$apply_out" "$apply_err" <<'PY'
|
|
import json
|
|
import sys
|
|
ns_rc = int(sys.argv[1])
|
|
secret_rc = int(sys.argv[2])
|
|
apply_rc = int(sys.argv[3])
|
|
secret_action = sys.argv[4]
|
|
paths = sys.argv[5:]
|
|
def text(path):
|
|
try:
|
|
return open(path, encoding="utf-8").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
payload = {
|
|
"ok": ns_rc == 0 and secret_rc == 0 and apply_rc == 0,
|
|
"namespace": "${namespace}",
|
|
"secret": {
|
|
"name": "${secretName}",
|
|
"action": secret_action,
|
|
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
|
|
"valuesPrinted": False,
|
|
},
|
|
"steps": {
|
|
"namespace": {"exitCode": ns_rc, "stdout": text(paths[0])[-4000:], "stderr": text(paths[1])[-4000:]},
|
|
"secret": {"exitCode": secret_rc, "stdout": text(paths[2])[-4000:], "stderr": text(paths[3])[-4000:]},
|
|
"apply": {"exitCode": apply_rc, "stdout": text(paths[4])[-8000:], "stderr": text(paths[5])[-4000:]},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function statusScript(expectedImage: string): string {
|
|
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"
|
|
rc=$?
|
|
printf '%s' "$rc" >"$tmp/$name.rc"
|
|
}
|
|
capture_json ns kubectl get namespace ${namespace}
|
|
capture_json deployments kubectl -n ${namespace} get deployments -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json statefulsets kubectl -n ${namespace} get statefulsets -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json pods kubectl -n ${namespace} get pods -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json services kubectl -n ${namespace} get services -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json pvc kubectl -n ${namespace} get pvc -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json secrets kubectl -n ${namespace} get secret ${secretName}
|
|
capture_json ingresses kubectl -n ${namespace} get ingress
|
|
capture_json quotas kubectl -n ${namespace} get resourcequota
|
|
capture_json limitranges kubectl -n ${namespace} get limitrange
|
|
python3 - "$tmp" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
|
|
def rc(name):
|
|
try:
|
|
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
|
except FileNotFoundError:
|
|
return 1
|
|
|
|
def load(name):
|
|
path = os.path.join(tmp, f"{name}.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
return json.load(open(path, encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def items(name):
|
|
data = load(name)
|
|
if not isinstance(data, dict):
|
|
return []
|
|
return data.get("items") or []
|
|
|
|
def deployment_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
desired = spec.get("replicas", 1)
|
|
available = status.get("availableReplicas", 0)
|
|
init_containers = ((spec.get("template") or {}).get("spec") or {}).get("initContainers", [])
|
|
containers = ((spec.get("template") or {}).get("spec") or {}).get("containers", [])
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"desired": desired,
|
|
"readyReplicas": status.get("readyReplicas", 0),
|
|
"availableReplicas": available,
|
|
"updatedReplicas": status.get("updatedReplicas", 0),
|
|
"ready": available >= desired,
|
|
"images": [c.get("image") for c in containers],
|
|
"initImages": [c.get("image") for c in init_containers],
|
|
}
|
|
|
|
def statefulset_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
desired = spec.get("replicas", 1)
|
|
ready = status.get("readyReplicas", 0)
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"desired": desired,
|
|
"readyReplicas": ready,
|
|
"currentReplicas": status.get("currentReplicas", 0),
|
|
"updatedReplicas": status.get("updatedReplicas", 0),
|
|
"ready": ready >= desired,
|
|
"images": [c.get("image") for c in ((spec.get("template") or {}).get("spec") or {}).get("containers", [])],
|
|
}
|
|
|
|
def pod_summary(item):
|
|
status = item.get("status") or {}
|
|
container_statuses = status.get("containerStatuses") or []
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"phase": status.get("phase"),
|
|
"ready": all((cs.get("ready") is True) for cs in container_statuses) if container_statuses else False,
|
|
"restarts": sum(int(cs.get("restartCount") or 0) for cs in container_statuses),
|
|
"nodeName": (item.get("spec") or {}).get("nodeName"),
|
|
"containers": [
|
|
{
|
|
"name": cs.get("name"),
|
|
"ready": cs.get("ready"),
|
|
"restartCount": cs.get("restartCount"),
|
|
"image": cs.get("image"),
|
|
"state": list((cs.get("state") or {}).keys()),
|
|
"stateDetail": cs.get("state") or {},
|
|
}
|
|
for cs in container_statuses
|
|
],
|
|
"initContainers": [
|
|
{
|
|
"name": cs.get("name"),
|
|
"ready": cs.get("ready"),
|
|
"restartCount": cs.get("restartCount"),
|
|
"image": cs.get("image"),
|
|
"state": list((cs.get("state") or {}).keys()),
|
|
"stateDetail": cs.get("state") or {},
|
|
}
|
|
for cs in (status.get("initContainerStatuses") or [])
|
|
],
|
|
"conditions": [
|
|
{
|
|
"type": condition.get("type"),
|
|
"status": condition.get("status"),
|
|
"reason": condition.get("reason"),
|
|
"message": condition.get("message"),
|
|
}
|
|
for condition in status.get("conditions", [])
|
|
],
|
|
}
|
|
|
|
def service_summary(item):
|
|
spec = item.get("spec") or {}
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"type": spec.get("type", "ClusterIP"),
|
|
"clusterIP": spec.get("clusterIP"),
|
|
"ports": [
|
|
{
|
|
"name": p.get("name"),
|
|
"port": p.get("port"),
|
|
"targetPort": p.get("targetPort"),
|
|
"nodePort": p.get("nodePort"),
|
|
}
|
|
for p in spec.get("ports", [])
|
|
],
|
|
}
|
|
|
|
def pvc_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
req = (spec.get("resources") or {}).get("requests") or {}
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"phase": status.get("phase"),
|
|
"storageClassName": spec.get("storageClassName"),
|
|
"requestedStorage": req.get("storage"),
|
|
}
|
|
|
|
def resource_findings(kind, collection):
|
|
findings = []
|
|
for item in collection:
|
|
spec = item.get("spec") or {}
|
|
template_spec = ((spec.get("template") or {}).get("spec") or {})
|
|
if template_spec.get("hostNetwork") is True:
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "field": "hostNetwork"})
|
|
all_containers = [(container, "containers") for container in template_spec.get("containers", [])] + [(container, "initContainers") for container in template_spec.get("initContainers", [])]
|
|
for container, container_group in all_containers:
|
|
resources = container.get("resources") or {}
|
|
if resources.get("requests"):
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.requests"})
|
|
if resources.get("limits"):
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.limits"})
|
|
for port in container.get("ports", []):
|
|
if "hostPort" in port:
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "hostPort", "value": port.get("hostPort")})
|
|
return findings
|
|
|
|
deployments = items("deployments")
|
|
statefulsets = items("statefulsets")
|
|
services = items("services")
|
|
pods = items("pods")
|
|
pvcs = items("pvc")
|
|
secret = load("secrets")
|
|
secret_keys = sorted(((secret or {}).get("data") or {}).keys())
|
|
missing_secret_keys = [key for key in ${JSON.stringify(requiredSecretKeys)} if key not in secret_keys]
|
|
service_violations = []
|
|
for svc in services:
|
|
spec = svc.get("spec") or {}
|
|
if spec.get("type", "ClusterIP") != "ClusterIP":
|
|
service_violations.append({"name": svc["metadata"]["name"], "type": spec.get("type")})
|
|
for port in spec.get("ports", []):
|
|
if "nodePort" in port:
|
|
service_violations.append({"name": svc["metadata"]["name"], "nodePort": port.get("nodePort")})
|
|
resource_violations = resource_findings("Deployment", deployments) + resource_findings("StatefulSet", statefulsets)
|
|
expected_image = "${expectedImage}"
|
|
sub2api_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "${serviceName}"), None)
|
|
image_aligned = sub2api_deployment is not None and expected_image in sub2api_deployment.get("images", [])
|
|
boundary = {
|
|
"internalOnly": len(service_violations) == 0 and len(items("ingresses")) == 0,
|
|
"serviceViolations": service_violations,
|
|
"ingressCount": len(items("ingresses")),
|
|
"resourceQuotaCount": len(items("quotas")),
|
|
"limitRangeCount": len(items("limitranges")),
|
|
"resourceViolations": resource_violations,
|
|
}
|
|
workload_ready = all(d["ready"] for d in map(deployment_summary, deployments)) and all(s["ready"] for s in map(statefulset_summary, statefulsets))
|
|
payload = {
|
|
"ok": rc("ns") == 0 and workload_ready and image_aligned and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and len(missing_secret_keys) == 0,
|
|
"namespace": "${namespace}",
|
|
"namespaceExists": rc("ns") == 0,
|
|
"deployments": [deployment_summary(item) for item in deployments],
|
|
"statefulsets": [statefulset_summary(item) for item in statefulsets],
|
|
"pods": [pod_summary(item) for item in pods],
|
|
"services": [service_summary(item) for item in services],
|
|
"pvcs": [pvc_summary(item) for item in pvcs],
|
|
"secret": {
|
|
"name": "${secretName}",
|
|
"exists": rc("secrets") == 0,
|
|
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
|
|
"missingKeys": missing_secret_keys,
|
|
"valuesPrinted": False,
|
|
},
|
|
"imageControl": {
|
|
"desiredImage": expected_image,
|
|
"configPath": "config/platform-infra/sub2api.yaml",
|
|
"aligned": image_aligned,
|
|
"runningImages": sub2api_deployment.get("images", []) if sub2api_deployment else [],
|
|
},
|
|
"boundary": boundary,
|
|
"serviceDns": "${serviceName}.${namespace}.svc.cluster.local:8080",
|
|
"next": {
|
|
"apply": "bun scripts/cli.ts platform-infra sub2api apply --confirm",
|
|
"validate": "bun scripts/cli.ts platform-infra sub2api validate",
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function validateScript(): string {
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
kubectl get --raw /api/v1/namespaces/${namespace}/services/${serviceName}:8080/proxy/health >"$tmp/health.body" 2>"$tmp/health.err"
|
|
health_rc=$?
|
|
kubectl get --raw /api/v1/namespaces/${namespace}/services/${serviceName}:8080/proxy/ >"$tmp/root.body" 2>"$tmp/root.err"
|
|
root_rc=$?
|
|
pg_pod="$(kubectl -n ${namespace} get pod -l app.kubernetes.io/name=sub2api-postgres -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pg-pod.err")"
|
|
redis_pod="$(kubectl -n ${namespace} get pod -l app.kubernetes.io/name=sub2api-redis -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/redis-pod.err")"
|
|
if [ -n "$pg_pod" ]; then
|
|
kubectl -n ${namespace} exec "$pg_pod" -- pg_isready -U sub2api -d sub2api -h 127.0.0.1 >"$tmp/pg.out" 2>"$tmp/pg.err"
|
|
pg_rc=$?
|
|
else
|
|
pg_rc=1
|
|
printf '%s\\n' 'sub2api postgres pod not found' >"$tmp/pg.err"
|
|
fi
|
|
if [ -n "$redis_pod" ]; then
|
|
kubectl -n ${namespace} exec "$redis_pod" -- redis-cli ping >"$tmp/redis.out" 2>"$tmp/redis.err"
|
|
redis_rc=$?
|
|
else
|
|
redis_rc=1
|
|
printf '%s\\n' 'sub2api redis pod not found' >"$tmp/redis.err"
|
|
fi
|
|
python3 - "$tmp" "$health_rc" "$root_rc" "$pg_rc" "$redis_rc" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
health_rc, root_rc, pg_rc, redis_rc = [int(value) for value in sys.argv[2:]]
|
|
|
|
def text(name, limit=4000):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
data = open(path, encoding="utf-8", errors="replace").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
return data[-limit:]
|
|
|
|
health_body = text("health.body", 2000)
|
|
root_body = text("root.body", 2000)
|
|
payload = {
|
|
"ok": health_rc == 0 and root_rc == 0 and pg_rc == 0 and redis_rc == 0,
|
|
"namespace": "${namespace}",
|
|
"serviceDns": "${serviceName}.${namespace}.svc.cluster.local:8080",
|
|
"checks": {
|
|
"sub2apiHealthViaKubernetesServiceProxy": {
|
|
"exitCode": health_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${namespace}/services/${serviceName}:8080/proxy/health",
|
|
"bodyPreview": health_body,
|
|
"stderr": text("health.err", 2000),
|
|
},
|
|
"sub2apiRootViaKubernetesServiceProxy": {
|
|
"exitCode": root_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${namespace}/services/${serviceName}:8080/proxy/",
|
|
"bodyBytes": len(root_body.encode("utf-8")),
|
|
"bodyPreview": root_body[:400],
|
|
"stderr": text("root.err", 2000),
|
|
},
|
|
"postgresPgIsReady": {
|
|
"exitCode": pg_rc,
|
|
"stdout": text("pg.out", 2000),
|
|
"stderr": text("pg.err", 2000),
|
|
},
|
|
"redisPing": {
|
|
"exitCode": redis_rc,
|
|
"stdout": text("redis.out", 2000),
|
|
"stderr": text("redis.err", 2000),
|
|
},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
async function capture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
return await runSshCommandCapture(config, target, args, input);
|
|
}
|
|
|
|
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
|
const trimmed = stdout.trim();
|
|
if (trimmed.length === 0) return null;
|
|
const start = trimmed.indexOf("{");
|
|
const end = trimmed.lastIndexOf("}");
|
|
if (start === -1 || end === -1 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
|
if (value === null) return defaultValue;
|
|
const field = value[key];
|
|
return typeof field === "boolean" ? field : defaultValue;
|
|
}
|
|
|
|
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
|
const full = options.full ?? false;
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
|
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
|
stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
|
|
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
|
|
};
|
|
}
|