feat: manage frontend auth from YAML source
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
This commit is contained in:
+2
-2
@@ -456,8 +456,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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<Record<string, unknown> | 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<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return { ...auth.target, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function remoteSummary(result: { exitCode: number; stdout: string; stderr: string }): Record<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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)];
|
||||
}
|
||||
@@ -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<Record<string, unknown> | 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",
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user