From 44b716538e68f5c7b88443fe4beea261fc0dddf9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 15:20:54 +0200 Subject: [PATCH] feat: manage frontend auth from YAML source --- config/unidesk-host-k8s.yaml | 16 +- scripts/cli.ts | 4 +- .../native/deploy/render-unidesk-host-k8s.mjs | 26 +- scripts/src/frontend-auth.ts | 528 ++++++++++++++++++ scripts/src/frontend.ts | 21 + 5 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 scripts/src/frontend-auth.ts create mode 100644 scripts/src/frontend.ts diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index fe08aaa7..abcd018b 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -73,10 +73,24 @@ runtimeConfig: taskPendingTimeoutMs: "600000" databasePoolMax: "4" sessionTtlSeconds: "28800" - authUsername: admin sshClientRouteAllowlist: "*" microservicesJson: "[]" +runtimeAuth: + sourceRef: + path: .env/unidesk.auth + format: linePair + usernameLine: 1 + passwordLine: 2 + target: + route: NC01:k3s + namespace: unidesk + configMapName: unidesk-runtime-config + usernameKey: AUTH_USERNAME + secretName: unidesk-runtime-secrets + passwordKey: AUTH_PASSWORD + rolloutDeployment: frontend + runtimeSecrets: sourceRef: path: .state/secrets/unidesk-host-k8s.env diff --git a/scripts/cli.ts b/scripts/cli.ts index e11b9f83..ff30c6a4 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -456,8 +456,8 @@ async function main(): Promise { } if (top === "frontend") { - const { runFrontendPublicExposureCommand } = await import("./src/frontend-public-exposure"); - const result = await runFrontendPublicExposureCommand(readConfig(), args.slice(1)); + const { runFrontendCommand } = await import("./src/frontend"); + const result = await runFrontendCommand(readConfig(), args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; if (isRenderedCliResult(result)) { emitText(result.renderedText, result.command || commandName); diff --git a/scripts/native/deploy/render-unidesk-host-k8s.mjs b/scripts/native/deploy/render-unidesk-host-k8s.mjs index 70c5dd38..2b62d4bc 100755 --- a/scripts/native/deploy/render-unidesk-host-k8s.mjs +++ b/scripts/native/deploy/render-unidesk-host-k8s.mjs @@ -13,6 +13,11 @@ function record(value, path) { return value; } +function positiveInteger(value, path) { + if (!Number.isInteger(value) || value < 1) throw new Error(`${path} must be a positive integer`); + return value; +} + function readEnvFile(path) { const values = {}; const text = readFileSync(path, "utf8"); @@ -26,6 +31,15 @@ function readEnvFile(path) { return values; } +function readLinePairCredential(path, usernameLine, passwordLine) { + const lines = readFileSync(path, "utf8").split(/\r?\n/u); + const username = (lines[usernameLine - 1] || "").trim(); + const password = (lines[passwordLine - 1] || "").trim(); + if (!username) throw new Error(`missing username line ${usernameLine} in ${path}`); + if (!password) throw new Error(`missing password line ${passwordLine} in ${path}`); + return { username, password }; +} + function sha(value) { return createHash("sha256").update(value).digest("hex").slice(0, 16); } @@ -92,13 +106,23 @@ const services = record(config.services, "services"); const backend = record(services.backendCore, "services.backendCore"); const frontend = record(services.frontend, "services.frontend"); const runtimeConfig = record(config.runtimeConfig, "runtimeConfig"); +const runtimeAuth = record(config.runtimeAuth, "runtimeAuth"); +const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef"); const runtimeSecrets = record(config.runtimeSecrets, "runtimeSecrets"); const secretTarget = record(runtimeSecrets.target, "runtimeSecrets.target"); const secretKeys = record(secretTarget.keys, "runtimeSecrets.target.keys"); const namespace = required(target.namespace, "target.namespace"); const secretName = required(secretTarget.name, "runtimeSecrets.target.name"); +const authSourcePath = resolve(required(runtimeAuthSourceRef.path, "runtimeAuth.sourceRef.path")); +if (required(runtimeAuthSourceRef.format, "runtimeAuth.sourceRef.format") !== "linePair") throw new Error("runtimeAuth.sourceRef.format must be linePair"); +const authCredential = readLinePairCredential( + authSourcePath, + positiveInteger(runtimeAuthSourceRef.usernameLine, "runtimeAuth.sourceRef.usernameLine"), + positiveInteger(runtimeAuthSourceRef.passwordLine, "runtimeAuth.sourceRef.passwordLine"), +); const sourcePath = resolve(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path")); const secrets = readEnvFile(sourcePath); +secrets[secretKeys.authPassword] = authCredential.password; const requiredSecretKeys = Object.values(secretKeys); for (const key of requiredSecretKeys) { if (typeof secrets[key] !== "string" || secrets[key].length === 0) throw new Error(`missing ${key} in ${sourcePath}`); @@ -160,7 +184,7 @@ data: TASK_PENDING_TIMEOUT_MS: ${yamlScalar(required(runtimeConfig.taskPendingTimeoutMs, "runtimeConfig.taskPendingTimeoutMs"))} DATABASE_POOL_MAX: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))} SESSION_TTL_SECONDS: ${yamlScalar(required(runtimeConfig.sessionTtlSeconds, "runtimeConfig.sessionTtlSeconds"))} - AUTH_USERNAME: ${yamlScalar(required(runtimeConfig.authUsername, "runtimeConfig.authUsername"))} + AUTH_USERNAME: ${yamlScalar(authCredential.username)} UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: ${yamlScalar(required(runtimeConfig.sshClientRouteAllowlist, "runtimeConfig.sshClientRouteAllowlist"))} MICROSERVICES_JSON: ${yamlScalar(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"))} NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))} diff --git a/scripts/src/frontend-auth.ts b/scripts/src/frontend-auth.ts new file mode 100644 index 00000000..c8de2394 --- /dev/null +++ b/scripts/src/frontend-auth.ts @@ -0,0 +1,528 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import type { RenderedCliResult } from "./output"; +import { capture, parseJsonOutput } from "./platform-infra-public-service"; +import { asRecord, integerField, readYamlRecord, recordField, stringField } from "./platform-infra-ops-library"; + +const configPath = rootPath("config", "unidesk-host-k8s.yaml"); +const configLabel = "config/unidesk-host-k8s.yaml"; +const fieldManager = "unidesk-frontend-auth"; + +type Action = "plan" | "apply" | "status" | "validate"; + +interface RuntimeAuthConfig { + targetId: string; + source: { path: string; format: "linePair"; usernameLine: number; passwordLine: number }; + target: { + route: string; + namespace: string; + configMapName: string; + usernameKey: string; + secretName: string; + passwordKey: string; + rolloutDeployment: string; + }; +} + +interface AuthMaterial { + sourcePath: string; + sourcePathDisplay: string; + sourceExists: boolean; + username: string | null; + password: string | null; + usernameByteCount: number | null; + passwordByteCount: number | null; + fingerprint: string | null; +} + +interface AuthOptions { + confirm: boolean; + dryRun: boolean; + full: boolean; + raw: boolean; +} + +export async function runFrontendAuthCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const [actionRaw = "plan", ...rest] = args; + const action = parseAction(actionRaw); + const options = parseOptions(rest); + if (action === "plan") { + const result = plan(); + return options.full || options.raw ? result : renderPlan(result); + } + if (action === "apply") { + const result = await apply(config, options); + return options.full || options.raw ? result : renderApply(result); + } + if (action === "status") { + const result = await status(config, options); + return options.full || options.raw ? result : renderStatus(result); + } + const result = await validate(config, options); + return options.full || options.raw ? result : renderValidate(result); +} + +function plan(): Record { + const auth = readRuntimeAuthConfig(); + const material = readAuthMaterial(auth); + return { + ok: material.sourceExists && material.username !== null && material.password !== null, + action: "frontend-auth-plan", + mutation: false, + config: configSummary(auth), + localSource: materialSummary(auth, material), + target: targetSummary(auth), + policy: { + sourceAuthority: `${configLabel}.runtimeAuth.sourceRef`, + runtimeReverseEngineering: false, + valuesPrinted: false, + }, + next: { + apply: "bun scripts/cli.ts frontend auth apply --confirm", + status: "bun scripts/cli.ts frontend auth status", + validate: "bun scripts/cli.ts frontend auth validate", + }, + valuesPrinted: false, + }; +} + +async function apply(config: UniDeskConfig, options: AuthOptions): Promise> { + const auth = readRuntimeAuthConfig(); + const material = readAuthMaterial(auth); + const base = { + action: "frontend-auth-apply", + config: configSummary(auth), + localSource: materialSummary(auth, material), + target: targetSummary(auth), + valuesPrinted: false, + }; + if (!material.sourceExists || material.username === null || material.password === null || material.fingerprint === null) { + return { ok: false, ...base, mutation: false, mode: "blocked-local-source" }; + } + if (!options.confirm || options.dryRun) { + return { ok: true, ...base, mutation: false, mode: "dry-run" }; + } + const remote = await capture(config, auth.target.route, ["sh"], applyScript(auth, material)); + const parsed = parseJsonOutput(remote.stdout); + const runtime = await status(config, { ...options, raw: false }); + return { + ok: remote.exitCode === 0 && parsed?.ok === true && runtime.ok === true, + ...base, + mutation: true, + mode: "confirmed", + remote: remoteSummary(remote), + summary: parsed ?? { ok: false, parseError: "remote-json-missing", valuesPrinted: false }, + runtime: runtime.summary, + }; +} + +async function status(config: UniDeskConfig, options: AuthOptions): Promise> { + const auth = readRuntimeAuthConfig(); + const material = readAuthMaterial(auth); + const remote = await capture(config, auth.target.route, ["sh"], statusScript(auth, material.fingerprint)); + const parsed = parseJsonOutput(remote.stdout); + return { + ok: remote.exitCode === 0 && parsed?.ok === true, + action: "frontend-auth-status", + mutation: false, + config: configSummary(auth), + localSource: materialSummary(auth, material), + target: targetSummary(auth), + remote: remoteSummary(remote), + summary: parsed ?? { ok: false, parseError: "remote-json-missing", valuesPrinted: false }, + rawCaptureOmitted: options.raw ? true : undefined, + valuesPrinted: false, + }; +} + +async function validate(config: UniDeskConfig, options: AuthOptions): Promise> { + const planned = plan(); + const state = await status(config, options); + const localOk = planned.ok === true; + const runtimeOk = state.ok === true; + return { + ok: localOk && runtimeOk, + action: "frontend-auth-validate", + mutation: false, + checks: [ + { name: "local-source", ok: localOk, detail: configLabel }, + { name: "runtime-target", ok: runtimeOk, detail: `${targetSummary(readRuntimeAuthConfig()).namespace}/${targetSummary(readRuntimeAuthConfig()).secretName}` }, + ], + localSource: planned.localSource, + runtime: state.summary, + valuesPrinted: false, + }; +} + +function readRuntimeAuthConfig(): RuntimeAuthConfig { + const root = readYamlRecord(configPath, "UniDeskHostK8sDeployment"); + const target = recordField(root, "target", configLabel); + const runtimeAuth = recordField(root, "runtimeAuth", configLabel); + const source = recordField(runtimeAuth, "sourceRef", `${configLabel}.runtimeAuth`); + const targetAuth = recordField(runtimeAuth, "target", `${configLabel}.runtimeAuth`); + const format = stringField(source, "format", `${configLabel}.runtimeAuth.sourceRef`); + if (format !== "linePair") throw new Error(`${configLabel}.runtimeAuth.sourceRef.format must be linePair`); + return { + targetId: stringField(target, "id", `${configLabel}.target`), + source: { + path: stringField(source, "path", `${configLabel}.runtimeAuth.sourceRef`), + format, + usernameLine: integerField(source, "usernameLine", `${configLabel}.runtimeAuth.sourceRef`), + passwordLine: integerField(source, "passwordLine", `${configLabel}.runtimeAuth.sourceRef`), + }, + target: { + route: stringField(targetAuth, "route", `${configLabel}.runtimeAuth.target`), + namespace: stringField(targetAuth, "namespace", `${configLabel}.runtimeAuth.target`), + configMapName: stringField(targetAuth, "configMapName", `${configLabel}.runtimeAuth.target`), + usernameKey: stringField(targetAuth, "usernameKey", `${configLabel}.runtimeAuth.target`), + secretName: stringField(targetAuth, "secretName", `${configLabel}.runtimeAuth.target`), + passwordKey: stringField(targetAuth, "passwordKey", `${configLabel}.runtimeAuth.target`), + rolloutDeployment: stringField(targetAuth, "rolloutDeployment", `${configLabel}.runtimeAuth.target`), + }, + }; +} + +function readAuthMaterial(auth: RuntimeAuthConfig): AuthMaterial { + const sourcePath = resolveSourcePath(auth.source.path); + const sourcePathDisplay = displayPath(sourcePath); + if (!existsSync(sourcePath)) { + return { sourcePath, sourcePathDisplay, sourceExists: false, username: null, password: null, usernameByteCount: null, passwordByteCount: null, fingerprint: null }; + } + const lines = readFileSync(sourcePath, "utf8").split(/\r?\n/u); + const username = lines[auth.source.usernameLine - 1]?.trim() || null; + const password = lines[auth.source.passwordLine - 1]?.trim() || null; + return { + sourcePath, + sourcePathDisplay, + sourceExists: true, + username, + password, + usernameByteCount: username === null ? null : Buffer.byteLength(username, "utf8"), + passwordByteCount: password === null ? null : Buffer.byteLength(password, "utf8"), + fingerprint: username === null || password === null ? null : fingerprint([username, password]), + }; +} + +function applyScript(auth: RuntimeAuthConfig, material: AuthMaterial): string { + const manifest = renderManifest(auth, material); + const manifestB64 = Buffer.from(manifest, "utf8").toString("base64"); + const summaryB64 = Buffer.from(JSON.stringify({ + namespace: auth.target.namespace, + configMapName: auth.target.configMapName, + usernameKey: auth.target.usernameKey, + secretName: auth.target.secretName, + passwordKey: auth.target.passwordKey, + rolloutDeployment: auth.target.rolloutDeployment, + sourceRef: auth.source.path, + fingerprint: material.fingerprint, + valuesPrinted: false, + }), "utf8").toString("base64"); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +manifest="$tmp/frontend-auth.yaml" +printf '%s' '${manifestB64}' | base64 -d >"$manifest" +kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" +apply_rc=$? +if [ "$apply_rc" -eq 0 ]; then + kubectl -n ${sh(auth.target.namespace)} rollout restart deployment/${sh(auth.target.rolloutDeployment)} >"$tmp/rollout.out" 2>"$tmp/rollout.err" + rollout_rc=$? +else + : >"$tmp/rollout.out" + printf '%s\\n' 'skipped because apply failed' >"$tmp/rollout.err" + rollout_rc=1 +fi +python3 - "$apply_rc" "$rollout_rc" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY' +import base64, json, sys +apply_rc, rollout_rc = int(sys.argv[1]), int(sys.argv[2]) +def tail(path, limit=4000): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +summary = json.loads(base64.b64decode("${summaryB64}").decode("utf-8")) +payload = { + "ok": apply_rc == 0 and rollout_rc == 0, + **summary, + "steps": { + "apply": {"exitCode": apply_rc, "stdoutTail": tail(sys.argv[3]), "stderrTail": tail(sys.argv[4])}, + "rolloutRestart": {"exitCode": rollout_rc, "stdoutTail": tail(sys.argv[5]), "stderrTail": tail(sys.argv[6])}, + }, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +function statusScript(auth: RuntimeAuthConfig, expectedFingerprint: string | null): string { + const expected = expectedFingerprint ?? ""; + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +kubectl -n ${sh(auth.target.namespace)} get configmap ${sh(auth.target.configMapName)} -o json >"$tmp/cm.json" 2>"$tmp/cm.err" +cm_rc=$? +kubectl -n ${sh(auth.target.namespace)} get secret ${sh(auth.target.secretName)} -o json >"$tmp/secret.json" 2>"$tmp/secret.err" +secret_rc=$? +kubectl -n ${sh(auth.target.namespace)} get deployment ${sh(auth.target.rolloutDeployment)} -o json >"$tmp/deploy.json" 2>"$tmp/deploy.err" +deploy_rc=$? +python3 - "$tmp" "$cm_rc" "$secret_rc" "$deploy_rc" <<'PY' +import json, os, sys +tmp, cm_rc, secret_rc, deploy_rc = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]) +def load(name): + try: + return json.load(open(os.path.join(tmp, name), encoding="utf-8")) + except Exception: + return {} +cm = load("cm.json") +secret = load("secret.json") +deploy = load("deploy.json") +cm_ann = ((cm.get("metadata") or {}).get("annotations") or {}) +secret_ann = ((secret.get("metadata") or {}).get("annotations") or {}) +cm_data = cm.get("data") or {} +secret_data = secret.get("data") or {} +desired = "${expected}" +observed_cm_fp = cm_ann.get("unidesk.ai/frontend-auth-fingerprint") +observed_secret_fp = secret_ann.get("unidesk.ai/frontend-auth-fingerprint") +available = int(((deploy.get("status") or {}).get("availableReplicas") or 0)) +replicas = int(((deploy.get("status") or {}).get("replicas") or 0)) +checks = [ + {"name": "configmap", "ok": cm_rc == 0, "detail": "${auth.target.configMapName}"}, + {"name": "username-key", "ok": "${auth.target.usernameKey}" in cm_data, "detail": "${auth.target.usernameKey}"}, + {"name": "secret", "ok": secret_rc == 0, "detail": "${auth.target.secretName}"}, + {"name": "password-key", "ok": "${auth.target.passwordKey}" in secret_data, "detail": "${auth.target.passwordKey}"}, + {"name": "fingerprint", "ok": bool(desired) and observed_cm_fp == desired and observed_secret_fp == desired, "detail": observed_secret_fp or "-"}, + {"name": "deployment", "ok": deploy_rc == 0 and replicas > 0 and available >= 1, "detail": "${auth.target.rolloutDeployment}"}, +] +payload = { + "ok": all(item["ok"] for item in checks), + "namespace": "${auth.target.namespace}", + "configMapName": "${auth.target.configMapName}", + "usernameKey": "${auth.target.usernameKey}", + "secretName": "${auth.target.secretName}", + "passwordKey": "${auth.target.passwordKey}", + "rolloutDeployment": "${auth.target.rolloutDeployment}", + "expectedFingerprint": desired or None, + "observedFingerprint": observed_secret_fp, + "checks": checks, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +function renderManifest(auth: RuntimeAuthConfig, material: AuthMaterial): string { + if (material.username === null || material.password === null || material.fingerprint === null) throw new Error("auth material is incomplete"); + const annotations = [ + ` unidesk.ai/source-ref: ${yaml(auth.source.path)}`, + ` unidesk.ai/frontend-auth-fingerprint: ${yaml(material.fingerprint)}`, + ].join("\n"); + return `apiVersion: v1 +kind: ConfigMap +metadata: + name: ${auth.target.configMapName} + namespace: ${auth.target.namespace} + annotations: +${annotations} +data: + ${auth.target.usernameKey}: ${yaml(material.username)} +--- +apiVersion: v1 +kind: Secret +metadata: + name: ${auth.target.secretName} + namespace: ${auth.target.namespace} + annotations: +${annotations} +type: Opaque +stringData: + ${auth.target.passwordKey}: ${yaml(material.password)} +`; +} + +function parseAction(value: string): Action { + if (value === "plan" || value === "apply" || value === "status" || value === "validate") return value; + throw new Error(`frontend auth action must be plan, apply, status, or validate; got ${value}`); +} + +function parseOptions(args: string[]): AuthOptions { + let confirm = false; + let dryRun = false; + let full = false; + let raw = false; + for (const arg of args) { + if (arg === "--confirm") confirm = true; + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--full") full = true; + else if (arg === "--raw") { + raw = true; + full = true; + } else { + throw new Error(`unsupported frontend auth option: ${arg}`); + } + } + if (confirm && dryRun) throw new Error("frontend auth apply accepts only one of --confirm or --dry-run"); + return { confirm, dryRun, full, raw }; +} + +function configSummary(auth: RuntimeAuthConfig): Record { + return { + path: configLabel, + targetId: auth.targetId, + sourceRef: auth.source.path, + format: auth.source.format, + usernameLine: auth.source.usernameLine, + passwordLine: auth.source.passwordLine, + valuesPrinted: false, + }; +} + +function materialSummary(auth: RuntimeAuthConfig, material: AuthMaterial): Record { + return { + sourceRef: auth.source.path, + sourcePath: material.sourcePathDisplay, + exists: material.sourceExists, + requiredLines: { username: auth.source.usernameLine, password: auth.source.passwordLine }, + byteCounts: { username: material.usernameByteCount, password: material.passwordByteCount }, + fingerprint: material.fingerprint, + valuesPrinted: false, + }; +} + +function targetSummary(auth: RuntimeAuthConfig): Record { + return { ...auth.target, valuesPrinted: false }; +} + +function remoteSummary(result: { exitCode: number; stdout: string; stderr: string }): Record { + return { + exitCode: result.exitCode, + stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), + stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + stdoutTailOmitted: true, + stderrTailOmitted: true, + valuesPrinted: false, + }; +} + +function renderPlan(result: Record): RenderedCliResult { + const source = asRecord(result.localSource, "localSource"); + const target = asRecord(result.target, "target"); + return rendered(result, "frontend auth plan", [ + "FRONTEND AUTH PLAN", + ...table(["CHECK", "OK", "DETAIL"], [ + ["source", bool(source.exists), String(source.sourceRef ?? "-")], + ["line-pair", bool(source.fingerprint !== undefined && source.fingerprint !== null), `userLine=${source.requiredLines && asRecord(source.requiredLines, "requiredLines").username} passwordLine=${source.requiredLines && asRecord(source.requiredLines, "requiredLines").password}`], + ["target", "yes", `${target.namespace}/${target.secretName}`], + ]), + "", + `fingerprint: ${str(source.fingerprint)}`, + `valuesPrinted: ${str(result.valuesPrinted)}`, + "Next:", + " bun scripts/cli.ts frontend auth apply --confirm", + ]); +} + +function renderApply(result: Record): RenderedCliResult { + const summary = result.summary === undefined ? {} : asRecord(result.summary, "summary"); + const steps = summary.steps === undefined ? {} : asRecord(summary.steps, "summary.steps"); + if (Object.keys(summary).length === 0 || Object.keys(steps).length === 0) { + const source = asRecord(result.localSource, "localSource"); + return rendered(result, "frontend auth apply", [ + "FRONTEND AUTH APPLY", + `mode: ${str(result.mode)} mutation: ${str(result.mutation)} ok: ${bool(result.ok)}`, + ...table(["CHECK", "OK", "DETAIL"], [ + ["local-source", bool(source.fingerprint !== undefined && source.fingerprint !== null), str(source.sourceRef)], + ["target", "yes", `${str(asRecord(result.target, "target").namespace)}/${str(asRecord(result.target, "target").secretName)}`], + ]), + `valuesPrinted: ${str(result.valuesPrinted)}`, + ]); + } + return rendered(result, "frontend auth apply", [ + "FRONTEND AUTH APPLY", + `mode: ${str(result.mode)} mutation: ${str(result.mutation)} ok: ${bool(result.ok)}`, + ...table(["STEP", "OK", "DETAIL"], [ + ["local-source", bool(asRecord(result.localSource, "localSource").fingerprint !== undefined && asRecord(result.localSource, "localSource").fingerprint !== null), str(asRecord(result.localSource, "localSource").sourceRef)], + ["apply", bool(asRecord(steps.apply, "steps.apply").exitCode === 0), compact(str(asRecord(steps.apply, "steps.apply").stderrTail, str(asRecord(steps.apply, "steps.apply").stdoutTail)))], + ["rollout-restart", bool(asRecord(steps.rolloutRestart, "steps.rolloutRestart").exitCode === 0), compact(str(asRecord(steps.rolloutRestart, "steps.rolloutRestart").stdoutTail, str(asRecord(steps.rolloutRestart, "steps.rolloutRestart").stderrTail)))], + ["runtime-status", bool(asRecord(result.runtime, "runtime").ok), str(asRecord(result.runtime, "runtime").observedFingerprint)], + ]), + `valuesPrinted: ${str(result.valuesPrinted)}`, + ]); +} + +function renderStatus(result: Record): RenderedCliResult { + const summary = asRecord(result.summary, "summary"); + const rows = Array.isArray(summary.checks) ? summary.checks.map((item) => { + const row = asRecord(item, "check"); + return [str(row.name), bool(row.ok), str(row.detail)]; + }) : []; + return rendered(result, "frontend auth status", [ + "FRONTEND AUTH STATUS", + ...table(["CHECK", "OK", "DETAIL"], rows), + `fingerprint: ${str(summary.observedFingerprint)}`, + `valuesPrinted: ${str(result.valuesPrinted)}`, + ]); +} + +function renderValidate(result: Record): RenderedCliResult { + const rows = Array.isArray(result.checks) ? result.checks.map((item) => { + const row = asRecord(item, "check"); + return [str(row.name), bool(row.ok), str(row.detail)]; + }) : []; + return rendered(result, "frontend auth validate", [ + "FRONTEND AUTH VALIDATE", + ...table(["CHECK", "OK", "DETAIL"], rows), + `valuesPrinted: ${str(result.valuesPrinted)}`, + ]); +} + +function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { + return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function resolveSourcePath(path: string): string { + return isAbsolute(path) ? path : rootPath(path); +} + +function displayPath(path: string): string { + const root = rootPath(); + return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; +} + +function fingerprint(parts: string[]): string { + return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16); +} + +function yaml(value: string): string { + return JSON.stringify(value); +} + +function sh(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function str(value: unknown, fallback = "-"): string { + return value === undefined || value === null || value === "" ? fallback : String(value); +} + +function bool(value: unknown): string { + return value === true ? "yes" : value === false ? "no" : "-"; +} + +function compact(value: string): string { + return value.replace(/\s+/gu, " ").trim().slice(0, 120) || "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); + const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); + return [renderRow(headers), ...rows.map(renderRow)]; +} diff --git a/scripts/src/frontend.ts b/scripts/src/frontend.ts new file mode 100644 index 00000000..30a4eb79 --- /dev/null +++ b/scripts/src/frontend.ts @@ -0,0 +1,21 @@ +import type { UniDeskConfig } from "./config"; +import type { RenderedCliResult } from "./output"; +import { runFrontendAuthCommand } from "./frontend-auth"; +import { runFrontendPublicExposureCommand } from "./frontend-public-exposure"; + +export async function runFrontendCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const [scope = "public-exposure"] = args; + if (scope === "auth") return await runFrontendAuthCommand(config, args.slice(1)); + if (scope === "public-exposure") return await runFrontendPublicExposureCommand(config, args); + return { + ok: false, + error: "unsupported-frontend-command", + command: "frontend", + supportedScopes: ["auth", "public-exposure"], + examples: [ + "bun scripts/cli.ts frontend auth plan", + "bun scripts/cli.ts frontend auth apply --confirm", + "bun scripts/cli.ts frontend public-exposure validate", + ], + }; +}