diff --git a/config/hwlab-v03/secrets.yaml b/config/hwlab-v03/secrets.yaml index a67d19d2..eead6c1b 100644 --- a/config/hwlab-v03/secrets.yaml +++ b/config/hwlab-v03/secrets.yaml @@ -3,14 +3,40 @@ kind: hwlab-v03-secret-plane metadata: id: hwlab-v03-secret-plane owner: HWLAB - spec: pikasTech/HWLAB#2234 - boundary: D518/v0.3 only + spec: pikasTech/HWLAB#2277 + boundary: JD01/v0.3 read-only active target secretPlane: enabled: true - issue: pikasTech/HWLAB#2234 + issue: pikasTech/HWLAB#2277 enabledOnNodes: - - D518 + - JD01 valuesPrinted: false + readModel: + target: + node: JD01 + lane: v03 + runtimeNamespace: hwlab-v03 + infraNamespace: platform-infra + publicUrl: https://hwlab.pikapython.com + source: yaml-active-target + sourceRef: config/hwlab-v03/secrets.yaml#secretPlane.readModel.target + upstreamSourceRef: config/platform-infra/secret-plane.yaml#activeTarget + statusEvidence: + source: pikasTech/unidesk#1282 + issue: pikasTech/HWLAB#2277 + command: bun scripts/cli.ts platform-infra secret-plane status --target JD01 + validatedAt: "2026-06-30T03:21:49Z" + fingerprint: sha256:dbc852e80ed37ccc9d8596eb47976839c9c77e4a5afda972ce62ad29db9132dd + status: Ready + valuesPrinted: false + components: + esoController: Ready + esoWebhook: Ready + esoCertController: Ready + secretStore: Ready + clusterSecretStore: Ready + sync: Ready + consumer: Ready store: kind: ClusterSecretStore name: hwlab-secret-plane-vault-cluster @@ -19,7 +45,11 @@ secretPlane: - id: hwlab-secret-plane-smoke externalSecretName: hwlab-secret-plane-smoke targetSecretName: hwlab-secret-plane-smoke - purpose: Low-risk D518/v0.3 smoke secret for platform-infra secret-plane integration. + purpose: Low-risk JD01/v0.3 smoke secret for platform-infra secret-plane integration. + statusEvidence: + status: Ready + reason: JD01 platform-infra secret-plane PoC validated by pikasTech/unidesk#1282. + fingerprint: sha256:dbc852e80ed37ccc9d8596eb47976839c9c77e4a5afda972ce62ad29db9132dd data: - targetKey: password remoteRef: hwlab-secret-plane/poc diff --git a/internal/cloud/admin-secrets-readmodel.ts b/internal/cloud/admin-secrets-readmodel.ts new file mode 100644 index 00000000..319f2dc8 --- /dev/null +++ b/internal/cloud/admin-secrets-readmodel.ts @@ -0,0 +1,573 @@ +/* + * SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-06-30-p1-readonly-api. + * Responsibility: read-only, redacted admin secrets API model for Cloud Web. + */ +import { existsSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { sendJson } from "./server-http-utils.ts"; + +const CONTRACT_VERSION = "hwlab-admin-secrets-v1"; +const DEFAULT_CONFIG_RELATIVE_PATH = "config/hwlab-v03/secrets.yaml"; +const DEFAULT_TARGET = Object.freeze({ + node: "JD01", + lane: "v03", + runtimeNamespace: "hwlab-v03", + infraNamespace: "platform-infra", + publicUrl: "https://hwlab.pikapython.com", + source: "yaml-active-target" +}); +const STATUS_PRIORITY = Object.freeze([ + "SecretLeakBlocked", + "StoreUnavailable", + "SyncError", + "TargetMissing", + "RolloutPending", + "ValidationFailed", + "Ready", + "Unknown" +]); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +export async function handleAdminSecretsHttp(request, response, url, options = {}) { + try { + const auth = await authenticateAdmin(request, options); + if (!auth.ok) return sendJson(response, numericStatus(auth.httpStatus) ?? numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth); + + const readModel = buildAdminSecretsReadModel({ env: options.env ?? process.env }); + if (readModel.leakBlocked) return sendJson(response, 502, readModel.payload); + + if (request.method === "GET" && url.pathname === "/v1/admin/secrets/summary") { + return sendJson(response, 200, readModel.payload); + } + if (request.method === "GET" && url.pathname === "/v1/admin/secrets/external-secrets") { + return sendJson(response, 200, externalSecretsListPayload(readModel.payload, url)); + } + const detailMatch = url.pathname.match(/^\/v1\/admin\/secrets\/external-secrets\/([^/]+)\/([^/]+)$/u); + if (request.method === "GET" && detailMatch) { + const detail = externalSecretDetailPayload(readModel.payload, decodeURIComponent(detailMatch[1]), decodeURIComponent(detailMatch[2])); + return sendJson(response, detail.httpStatus ?? 200, detail); + } + if (request.method === "GET" && url.pathname === "/v1/admin/secrets/events") { + return sendJson(response, 200, eventsPayload(readModel.payload, url)); + } + return sendJson(response, 404, secretsError("not_found", "Admin secrets route is not implemented", 404)); + } catch (error) { + return sendJson(response, error.httpStatus ?? 500, secretsError(error.code ?? "admin_secrets_failed", error.message ?? "Admin secrets read model failed", error.httpStatus ?? 500, error.details)); + } +} + +export function buildAdminSecretsReadModel({ env = process.env } = {}) { + const { config, sourcePath, sourceRelativePath, sourceError } = readSecretPlaneConfig(env); + const secretPlane = record(config?.secretPlane); + const readModelConfig = record(secretPlane?.readModel); + const target = normalizeTarget(readModelConfig?.target, env, sourceRelativePath); + const evidence = normalizeEvidence(readModelConfig?.statusEvidence); + const store = normalizeStore(secretPlane?.store); + const externalSecrets = normalizeExternalSecrets(secretPlane, target, store, evidence, sourceRelativePath); + const events = buildEvents({ target, store, externalSecrets, evidence, sourceError }); + const summaryStatus = sourceError ? "Unknown" : aggregateStatus([ + ...componentStatuses(evidence), + ...externalSecrets.map((item) => item.status) + ]); + const leakBlocked = hasLeakRisk({ target, store, externalSecrets, events }); + const generatedAt = new Date().toISOString(); + const common = { + ok: !leakBlocked, + status: leakBlocked ? "blocked" : "ok", + contractVersion: CONTRACT_VERSION, + valuesPrinted: false, + secretMaterialStored: false, + generatedAt, + target, + source: { + kind: "yaml", + path: sourceRelativePath, + sourceRef: `${sourceRelativePath}#secretPlane`, + present: Boolean(sourcePath && !sourceError), + error: sourceError ? { code: "secret_plane_config_unavailable", message: sourceError } : null + } + }; + if (leakBlocked) { + return { + leakBlocked: true, + payload: { + ...common, + error: { + code: "SecretLeakBlocked", + message: "Admin secrets read model contained disallowed secret material indicators.", + layer: "admin-secrets-readmodel", + retryable: false, + valuesPrinted: false + } + } + }; + } + return { + leakBlocked: false, + payload: { + ...common, + summary: { + status: summaryStatus, + priority: STATUS_PRIORITY.indexOf(summaryStatus), + counts: { + externalSecrets: externalSecrets.length, + ready: externalSecrets.filter((item) => item.status === "Ready").length, + pending: externalSecrets.filter((item) => item.status === "RolloutPending" || item.status === "Unknown").length, + errors: externalSecrets.filter((item) => ["SecretLeakBlocked", "StoreUnavailable", "SyncError", "TargetMissing", "ValidationFailed"].includes(item.status)).length, + events: events.length + }, + eso: { + controller: evidence.components.esoController, + webhook: evidence.components.esoWebhook, + certController: evidence.components.esoCertController + }, + stores: [store], + consumerRollout: consumerRolloutSummary(externalSecrets), + latestEvidence: { + source: evidence.source, + issue: evidence.issue, + command: evidence.command, + validatedAt: evidence.validatedAt, + fingerprint: evidence.fingerprint, + valuesPrinted: false + } + }, + externalSecrets, + events + } + }; +} + +function externalSecretsListPayload(payload, url) { + const node = text(url.searchParams.get("node")).toUpperCase(); + const lane = text(url.searchParams.get("lane")).toLowerCase(); + const namespace = text(url.searchParams.get("namespace")); + const items = payload.externalSecrets.filter((item) => { + if (node && item.target.node !== node) return false; + if (lane && item.target.lane.toLowerCase() !== lane) return false; + if (namespace && item.namespace !== namespace) return false; + return true; + }); + return { + ok: true, + status: "ok", + contractVersion: CONTRACT_VERSION, + valuesPrinted: false, + secretMaterialStored: false, + generatedAt: payload.generatedAt, + target: payload.target, + items, + count: items.length + }; +} + +function externalSecretDetailPayload(payload, namespace, name) { + const item = payload.externalSecrets.find((secret) => secret.namespace === namespace && secret.name === name); + if (!item) return secretsError("external_secret_not_found", "ExternalSecret read model entry was not found", 404, { namespace, name }); + return { + ok: true, + status: "ok", + contractVersion: CONTRACT_VERSION, + valuesPrinted: false, + secretMaterialStored: false, + generatedAt: payload.generatedAt, + target: payload.target, + item + }; +} + +function eventsPayload(payload, url) { + const limit = positiveInteger(url.searchParams.get("limit"), 50); + const namespace = text(url.searchParams.get("namespace")); + const items = payload.events + .filter((event) => !namespace || event.namespace === namespace) + .slice(0, Math.min(limit, 200)); + return { + ok: true, + status: "ok", + contractVersion: CONTRACT_VERSION, + valuesPrinted: false, + secretMaterialStored: false, + generatedAt: payload.generatedAt, + target: payload.target, + events: items, + count: items.length + }; +} + +async function authenticateAdmin(request, options) { + const controller = options.accessController; + if (!controller || typeof controller.authenticate !== "function") return secretsError("access_controller_unavailable", "HWLAB access controller is not available", 503); + const auth = await controller.authenticate(request, { required: true }); + if (!auth.ok) return auth; + if (auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403); + return auth; +} + +function readSecretPlaneConfig(env) { + const configuredPath = text(env.HWLAB_ADMIN_SECRETS_CONFIG_PATH); + const candidates = [ + configuredPath, + path.join(repoRoot, DEFAULT_CONFIG_RELATIVE_PATH), + path.join(process.cwd(), DEFAULT_CONFIG_RELATIVE_PATH), + path.join("/workspace/hwlab", DEFAULT_CONFIG_RELATIVE_PATH) + ].filter(Boolean); + const sourcePath = candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? ""; + const sourceRelativePath = sourcePath.includes(DEFAULT_CONFIG_RELATIVE_PATH) ? DEFAULT_CONFIG_RELATIVE_PATH : sourcePath; + if (!sourcePath || !existsSync(sourcePath)) return { config: null, sourcePath, sourceRelativePath, sourceError: `missing ${sourceRelativePath}` }; + try { + return { config: parseAdminSecretsYaml(readFileSync(sourcePath, "utf8")), sourcePath, sourceRelativePath, sourceError: null }; + } catch (error) { + return { config: null, sourcePath, sourceRelativePath, sourceError: `invalid yaml: ${error.message ?? String(error)}` }; + } +} + +function parseAdminSecretsYaml(raw) { + const root = {}; + const stack = [{ indent: -1, value: root, pending: false, parent: null, key: "" }]; + for (const [lineIndex, rawLine] of String(raw ?? "").split(/\r?\n/u).entries()) { + if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue; + const indent = rawLine.match(/^ */u)?.[0].length ?? 0; + const content = rawLine.trim(); + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); + let current = stack[stack.length - 1]; + if (content.startsWith("- ")) { + current = ensureYamlArrayContainer(current); + const itemText = content.slice(2).trim(); + if (!itemText) { + const item = {}; + current.value.push(item); + stack.push({ indent, value: item, pending: false, parent: current.value, key: String(current.value.length - 1) }); + continue; + } + const keyValue = splitYamlKeyValue(itemText); + if (keyValue) { + const item = {}; + current.value.push(item); + assignYamlKeyValue(item, keyValue, lineIndex); + stack.push({ indent, value: item, pending: false, parent: current.value, key: String(current.value.length - 1) }); + } else { + current.value.push(parseYamlScalar(itemText)); + } + continue; + } + + const parent = current.value; + if (!record(parent)) throw new Error(`unsupported yaml mapping at line ${lineIndex + 1}`); + const keyValue = splitYamlKeyValue(content); + if (!keyValue) throw new Error(`unsupported yaml line ${lineIndex + 1}`); + const assigned = assignYamlKeyValue(parent, keyValue, lineIndex); + if (assigned.pending) stack.push({ indent, value: assigned.value, pending: true, parent, key: keyValue.key }); + } + return root; +} + +function ensureYamlArrayContainer(entry) { + if (Array.isArray(entry.value)) return entry; + if (entry.pending && entry.parent && entry.key) { + const items = []; + entry.parent[entry.key] = items; + entry.value = items; + entry.pending = false; + return entry; + } + throw new Error("unsupported yaml sequence placement"); +} + +function splitYamlKeyValue(value) { + const match = String(value ?? "").match(/^([^:]+):(.*)$/u); + if (!match) return null; + return { key: match[1].trim(), value: match[2].trim() }; +} + +function assignYamlKeyValue(target, keyValue, lineIndex) { + if (!keyValue.key) throw new Error(`empty yaml key at line ${lineIndex + 1}`); + if (!keyValue.value) { + const child = {}; + target[keyValue.key] = child; + return { value: child, pending: true }; + } + target[keyValue.key] = parseYamlScalar(keyValue.value); + return { value: target[keyValue.key], pending: false }; +} + +function parseYamlScalar(value) { + const textValue = String(value ?? "").trim(); + if (!textValue) return ""; + if (textValue === "true") return true; + if (textValue === "false") return false; + if (textValue === "null" || textValue === "~") return null; + if (/^-?\d+(?:\.\d+)?$/u.test(textValue)) return Number(textValue); + if ((textValue.startsWith("\"") && textValue.endsWith("\"")) || (textValue.startsWith("'") && textValue.endsWith("'"))) return textValue.slice(1, -1); + return textValue; +} + +function normalizeTarget(value, env, sourceRelativePath) { + const input = record(value); + const node = text(env.HWLAB_ADMIN_SECRETS_ACTIVE_NODE) || text(input?.node) || DEFAULT_TARGET.node; + const lane = text(env.HWLAB_ADMIN_SECRETS_ACTIVE_LANE) || text(input?.lane) || DEFAULT_TARGET.lane; + return { + node: node.toUpperCase(), + lane, + runtimeNamespace: text(input?.runtimeNamespace) || DEFAULT_TARGET.runtimeNamespace, + infraNamespace: text(input?.infraNamespace) || DEFAULT_TARGET.infraNamespace, + publicUrl: text(input?.publicUrl) || DEFAULT_TARGET.publicUrl, + source: text(input?.source) || DEFAULT_TARGET.source, + sourceRef: text(input?.sourceRef) || `${sourceRelativePath}#secretPlane.readModel.target`, + upstreamSourceRef: text(input?.upstreamSourceRef) || "config/platform-infra/secret-plane.yaml#activeTarget" + }; +} + +function normalizeEvidence(value) { + const input = record(value); + const components = record(input?.components); + return { + source: text(input?.source) || "pikasTech/unidesk#1282", + issue: text(input?.issue) || "pikasTech/HWLAB#2277", + command: text(input?.command) || "platform-infra secret-plane status --target JD01", + validatedAt: text(input?.validatedAt) || "2026-06-30T03:21:49Z", + fingerprint: text(input?.fingerprint), + status: normalizeStatus(input?.status, "Ready"), + components: { + esoController: normalizeStatus(components?.esoController, "Ready"), + esoWebhook: normalizeStatus(components?.esoWebhook, "Ready"), + esoCertController: normalizeStatus(components?.esoCertController, "Ready"), + secretStore: normalizeStatus(components?.secretStore, "Ready"), + clusterSecretStore: normalizeStatus(components?.clusterSecretStore, "Ready"), + sync: normalizeStatus(components?.sync, "Ready"), + consumer: normalizeStatus(components?.consumer, "Ready") + } + }; +} + +function normalizeStore(value) { + const input = record(value); + return { + kind: text(input?.kind) || "ClusterSecretStore", + name: text(input?.name) || "hwlab-secret-plane-vault-cluster", + namespace: text(input?.namespace) || "platform-infra", + scope: text(input?.scope) || (text(input?.kind) === "SecretStore" ? "namespace" : "cluster"), + status: "Ready", + reason: "JD01 secret-plane status evidence reports Store Ready", + valuesPrinted: false + }; +} + +function normalizeExternalSecrets(secretPlane, target, store, evidence, sourceRelativePath) { + const secrets = Array.isArray(secretPlane?.secrets) ? secretPlane.secrets : []; + return secrets.map((secret, index) => normalizeExternalSecret(secret, index, secretPlane, target, store, evidence, sourceRelativePath)); +} + +function normalizeExternalSecret(secret, index, secretPlane, target, store, evidence, sourceRelativePath) { + const item = record(secret) ?? {}; + const namespace = text(item.targetNamespace) || target.runtimeNamespace; + const data = Array.isArray(item.data) ? item.data.map((entry, dataIndex) => normalizeSecretData(entry, dataIndex)) : []; + const consumers = Array.isArray(item.consumers) ? item.consumers.map((consumer, consumerIndex) => normalizeConsumer(consumer, consumerIndex, evidence)) : []; + const fingerprint = text(record(item.statusEvidence)?.fingerprint) || evidence.fingerprint; + const status = normalizeStatus(record(item.statusEvidence)?.status, evidence.status); + const name = text(item.externalSecretName) || `external-secret-${index + 1}`; + const targetSecretName = text(item.targetSecretName) || name; + return { + id: text(item.id) || name, + name, + namespace, + target, + store: { kind: store.kind, name: store.name, namespace: store.namespace, scope: store.scope, status: store.status }, + status, + ready: status === "Ready", + reason: text(record(item.statusEvidence)?.reason) || `${evidence.source} validated JD01 read-only status`, + message: "脱敏 read model only; secret values are never read or returned.", + refreshInterval: text(item.refreshInterval) || text(secretPlane?.refreshInterval) || "1m", + createdAt: null, + lastRefreshTime: evidence.validatedAt, + targetSecret: { + name: targetSecretName, + namespace, + keys: data.map((entry) => ({ key: entry.targetKey, presence: fingerprint ? "present" : "unknown", fingerprint, valuesPrinted: false })), + resourceVersion: text(record(item.statusEvidence)?.resourceVersion) || null, + fingerprint, + valuesPrinted: false + }, + data, + consumers, + provenance: { + configSource: `${sourceRelativePath}#secretPlane.secrets[${index}]`, + statusSource: evidence.source, + issue: evidence.issue, + command: evidence.command, + validatedAt: evidence.validatedAt + }, + valuesPrinted: false, + secretMaterialStored: false + }; +} + +function normalizeSecretData(entry, index) { + const item = record(entry) ?? {}; + const remoteRef = text(item.remoteRef); + return { + targetKey: text(item.targetKey) || `key-${index + 1}`, + remoteRef: { + path: redactRemoteRef(remoteRef), + property: text(item.property) || null, + version: text(item.version) || null, + fingerprint: remoteRef ? sha256Text(remoteRef).slice(0, 16) : null, + redacted: true + } + }; +} + +function normalizeConsumer(value, index, evidence) { + const input = record(value) ?? {}; + const serviceId = text(input.serviceId) || `consumer-${index + 1}`; + return { + serviceId, + kind: text(input.kind) || "Deployment", + injectAs: text(input.injectAs) || "env", + envName: text(input.envName) || null, + status: evidence.components.consumer, + rollout: evidence.components.consumer === "Ready" ? "Ready" : "RolloutPending", + revision: text(input.revision) || null, + valuesPrinted: false + }; +} + +function buildEvents({ target, store, externalSecrets, evidence, sourceError }) { + const events = []; + if (sourceError) { + events.push(event("Unknown", "ConfigUnavailable", target.runtimeNamespace, "secret-plane", sourceError, target, evidence)); + } + for (const secret of externalSecrets) { + events.push(event(secret.status, `${secret.status}ExternalSecret`, secret.namespace, secret.name, secret.reason, target, evidence, { + resourceKind: "ExternalSecret", + resourceName: secret.name + })); + events.push(event(store.status, `${store.status}Store`, store.namespace, store.name, "SecretStore / ClusterSecretStore readiness is exposed as redacted status.", target, evidence, { + resourceKind: store.kind, + resourceName: store.name + })); + } + return events.sort((left, right) => String(right.observedAt).localeCompare(String(left.observedAt))); +} + +function event(status, reason, namespace, name, message, target, evidence, extra = {}) { + return { + status, + reason, + namespace, + resourceKind: extra.resourceKind || "SecretPlane", + resourceName: extra.resourceName || name, + message: summarizeMessage(message), + observedAt: evidence.validatedAt || new Date().toISOString(), + target, + traceId: null, + jobId: null, + source: evidence.source, + valuesPrinted: false + }; +} + +function componentStatuses(evidence) { + return Object.values(evidence.components); +} + +function consumerRolloutSummary(externalSecrets) { + const consumers = externalSecrets.flatMap((item) => item.consumers); + return { + total: consumers.length, + ready: consumers.filter((item) => item.rollout === "Ready").length, + pending: consumers.filter((item) => item.rollout !== "Ready").length, + revision: consumers.find((item) => item.revision)?.revision ?? null + }; +} + +function aggregateStatus(statuses) { + for (const status of STATUS_PRIORITY) if (statuses.includes(status)) return status; + return "Unknown"; +} + +function normalizeStatus(value, fallback = "Unknown") { + const textValue = text(value); + if (STATUS_PRIORITY.includes(textValue)) return textValue; + if (/^(ready|ok|healthy|synced)$/iu.test(textValue)) return "Ready"; + if (/^(notready|unavailable)$/iu.test(textValue)) return "StoreUnavailable"; + if (/^(pending|progressing)$/iu.test(textValue)) return "RolloutPending"; + if (/^(failed|error)$/iu.test(textValue)) return "SyncError"; + return fallback; +} + +function hasLeakRisk(value) { + const stack = [value]; + while (stack.length > 0) { + const current = stack.pop(); + if (Array.isArray(current)) { + stack.push(...current); + continue; + } + if (!current || typeof current !== "object") continue; + for (const [key, item] of Object.entries(current)) { + if (/^(value|raw|secretValue|decoded|base64|authorization|cookie|token|apiKey|password)$/u.test(key) && typeof item === "string" && item.trim()) return true; + if (item && typeof item === "object") stack.push(item); + } + } + return false; +} + +function redactRemoteRef(value) { + const textValue = text(value); + if (!textValue) return null; + const parts = textValue.split("/").filter(Boolean); + if (parts.length <= 1) return `${textValue.slice(0, 4)}...`; + return `${parts[0]}/.../${parts[parts.length - 1]}`; +} + +function summarizeMessage(value) { + const message = text(value); + return message.length > 220 ? `${message.slice(0, 217)}...` : message; +} + +function secretsError(code, message, status = 500, details = {}) { + return { + ok: false, + status: "failed", + contractVersion: CONTRACT_VERSION, + valuesPrinted: false, + secretMaterialStored: false, + error: { + code, + message, + layer: "admin-secrets", + retryable: false, + valuesPrinted: false, + ...details + }, + httpStatus: status + }; +} + +function numericStatus(value) { + const number = Number(value); + return Number.isInteger(number) && number >= 100 && number <= 599 ? number : null; +} + +function positiveInteger(value, fallback) { + const number = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(number) && number > 0 ? number : fallback; +} + +function record(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function text(value) { + if (value === null || value === undefined) return ""; + return String(value).trim(); +} + +function sha256Text(value) { + return createHash("sha256").update(text(value)).digest("hex"); +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 84d0b3cf..c70a0dff 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -77,6 +77,7 @@ import { handleSkillsHttp } from "./server-skills-http.ts"; import { emitHttpServerRequestSpan, httpRequestOtelTraceContext, newOtelSpanId } from "./otel-trace.ts"; import { decorateErrorPayloadForHttp, logErrorEnvelope } from "./error-envelope.ts"; import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts"; +import { handleAdminSecretsHttp } from "./admin-secrets-readmodel.ts"; import { configureSkillRuntime, ensureCodexSkillsAggregationSync @@ -752,6 +753,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/secrets" || url.pathname.startsWith("/v1/admin/secrets/")) { + await handleAdminSecretsHttp(request, response, url, options); + return; + } + if (url.pathname === "/v1/provider-profiles" && request.method === "GET") { await handleProviderProfileCatalogHttp(request, response, options); return; @@ -961,6 +967,7 @@ function navIdForRestPath(pathname, method = "GET") { if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code"; if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code"; if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles"; + if (pathname === "/v1/admin/secrets" || pathname.startsWith("/v1/admin/secrets/")) return "admin.secrets"; if (pathname === "/v1/provider-profiles" && verb === "GET") return "workbench.code"; if (pathname.startsWith("/v1/admin/access")) return "admin.access"; if (pathname.startsWith("/v1/admin/users")) return "admin.users"; diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 5905375b..bcc05774 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -582,7 +582,7 @@ test("v03 render keeps node identity as data instead of generated structure", as "scripts/run-bun.mjs", "scripts/gitops-render.mjs", "--lane", "v03", - "--node", "D518", + "--node", "JD01", "--catalog-path", staleCatalogPath, "--image-tag-mode", "full", "--source-branch", "v0.3", @@ -617,8 +617,8 @@ test("v03 render keeps node identity as data instead of generated structure", as const smokeExternalSecret = (externalSecretsJson.items ?? []).find((item) => item.kind === "ExternalSecret" && item.metadata?.name === "hwlab-secret-plane-smoke"); assert.ok(smokeExternalSecret, "expected v03 secret-plane smoke ExternalSecret"); assert.equal(smokeExternalSecret.metadata?.namespace, "hwlab-v03"); - assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2234"); - assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2234"); + assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2277"); + assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2277"); assert.equal(smokeExternalSecret.spec?.secretStoreRef?.kind, "ClusterSecretStore"); assert.equal(smokeExternalSecret.spec?.secretStoreRef?.name, "hwlab-secret-plane-vault-cluster"); assert.equal(smokeExternalSecret.spec?.target?.name, "hwlab-secret-plane-smoke"); @@ -665,7 +665,7 @@ test("v03 render keeps node identity as data instead of generated structure", as .flatMap((container) => container.env ?? []) .filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID"); assert.ok(agentRunNodeEnv.length > 0, "expected AgentRun provider id env to carry configured node id"); - assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]); + assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["JD01"]); } finally { await rm(outDir, { recursive: true, force: true }); @@ -673,9 +673,9 @@ test("v03 render keeps node identity as data instead of generated structure", as } }); -test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () => { - const outDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-v03-d518-render-test-")); - const scratchDir = path.join(".tmp", `gitops-render-v03-d518-${process.pid}-${Date.now()}`); +test("v03 render includes JD01 secret-plane smoke on JD01 gitops root", { timeout: 20000 }, async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-v03-jd01-render-test-")); + const scratchDir = path.join(".tmp", `gitops-render-v03-jd01-${process.pid}-${Date.now()}`); const staleCatalogPath = path.join(scratchDir, "artifact-catalog.v03.json"); try { const staleRevision = "0".repeat(40); @@ -714,7 +714,7 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () "--image-tag-mode", "full", "--source-branch", "v0.3", "--gitops-branch", "v0.3-gitops", - "--gitops-root", "deploy/gitops/node/d518", + "--gitops-root", "deploy/gitops/node/jd01", "--out", outDir, "--source-revision", "HEAD" ], { cwd: process.cwd(), encoding: "utf8" }); @@ -730,10 +730,10 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () assert.match(kustomization, /external-secrets\.yaml/u); const externalSecretsJson = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "external-secrets.yaml"), "utf8")); const smokeExternalSecret = (externalSecretsJson.items ?? []).find((item) => item.kind === "ExternalSecret" && item.metadata?.name === "hwlab-secret-plane-smoke"); - assert.ok(smokeExternalSecret, "expected D518 v03 secret-plane smoke ExternalSecret"); + assert.ok(smokeExternalSecret, "expected JD01 v03 secret-plane smoke ExternalSecret"); assert.equal(smokeExternalSecret.metadata?.namespace, "hwlab-v03"); - assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2234"); - assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2234"); + assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2277"); + assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2277"); assert.equal(smokeExternalSecret.spec?.secretStoreRef?.kind, "ClusterSecretStore"); assert.equal(smokeExternalSecret.spec?.secretStoreRef?.name, "hwlab-secret-plane-vault-cluster"); assert.equal(smokeExternalSecret.spec?.target?.name, "hwlab-secret-plane-smoke"); @@ -748,8 +748,8 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () .flatMap((item) => collectContainersFromItem(item)) .flatMap((container) => container.env ?? []) .filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID"); - assert.ok(agentRunNodeEnv.length > 0, "expected D518 node id to be inferred from gitops root"); - assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]); + assert.ok(agentRunNodeEnv.length > 0, "expected JD01 node id to be inferred from gitops root"); + assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["JD01"]); const opencode = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "opencode.yaml"), "utf8")); const opencodeDeployment = (opencode.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "opencode-server"); diff --git a/web/hwlab-cloud-web/src/api/adminSecrets.ts b/web/hwlab-cloud-web/src/api/adminSecrets.ts new file mode 100644 index 00000000..a1dae66f --- /dev/null +++ b/web/hwlab-cloud-web/src/api/adminSecrets.ts @@ -0,0 +1,19 @@ +import { fetchJson } from "./client"; +import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types"; + +export const adminSecretsAPI = { + summary: (): Promise> => fetchJson("/v1/admin/secrets/summary", { timeoutMs: 12000, timeoutName: "admin secrets summary" }), + externalSecrets: (params: { node?: string; lane?: string; namespace?: string } = {}): Promise> => fetchJson(`/v1/admin/secrets/external-secrets${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets list" }), + detail: (namespace: string, name: string): Promise> => fetchJson(`/v1/admin/secrets/external-secrets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, { timeoutMs: 12000, timeoutName: "admin secrets detail" }), + events: (params: { namespace?: string; limit?: number } = {}): Promise> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }) +}; + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const text = String(value ?? "").trim(); + if (text) search.set(key, text); + } + const body = search.toString(); + return body ? `?${body}` : ""; +} diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 57416bed..a67eb261 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -9,6 +9,7 @@ export { agentAPI } from "./agent"; export { hwpodAPI } from "./hwpod"; export { caserunAPI } from "./caserun"; export { accessAPI } from "./access"; +export { adminSecretsAPI } from "./adminSecrets"; export { providerProfilesAPI } from "./providerProfiles"; export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; @@ -18,6 +19,7 @@ export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecor import { fetchJson } from "./client"; import { accessAPI } from "./access"; +import { adminSecretsAPI } from "./adminSecrets"; import { agentAPI } from "./agent"; import { apiKeysAPI } from "./apiKeys"; import { authAPI } from "./auth"; @@ -38,6 +40,7 @@ export const api = { hwpod: hwpodAPI, caserun: caserunAPI, access: accessAPI, + adminSecrets: adminSecretsAPI, providerProfiles: providerProfilesAPI, apiKeys: apiKeysAPI, usage: usageAPI, diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 587efd69..846e2db6 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -35,7 +35,7 @@ const navSections = [ { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] }, { title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] }, { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] }, - { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] }, + { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "K", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] }, { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", icon: "M", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", icon: "T", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", icon: "?", navId: "system.help" }] } ] satisfies NavSection[]; diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 5a4f7d2c..91c2608f 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -27,6 +27,7 @@ const routes: RouteRecordRaw[] = [ { path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, navId: "user.billing", title: "充值与订阅", section: "user" } }, { path: "/performance", name: "Performance", component: () => import("@/views/PerformanceView.vue"), meta: { requiresAuth: true, navId: "system.performance", title: "性能监控", section: "system" } }, { path: "/admin/access", alias: ["/access"], name: "Access", component: () => import("@/views/admin/AccessView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.access", title: "授权管理", section: "admin" } }, + { path: "/admin/secrets", name: "AdminSecrets", component: () => import("@/views/admin/SecretsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.secrets", title: "密钥管理", section: "admin" } }, { path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.users", title: "用户管理", section: "admin" } }, { path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.billing", title: "兑换与订阅", section: "admin" } }, { path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 分组", section: "admin" } }, diff --git a/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts new file mode 100644 index 00000000..ae940d18 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts @@ -0,0 +1,257 @@ +// SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-06-30-p1-readonly-ui. +// Responsibility: normalize admin secrets read model for the Cloud Web view. +import type { AdminSecretsEvent, AdminSecretsExternalSecret, AdminSecretsStore, AdminSecretsSummaryResponse, AdminSecretsTarget } from "@/types"; +import { asRecord, nonEmptyString } from "@/utils"; + +export interface AdminSecretsTargetView { + node: string; + lane: string; + runtimeNamespace: string; + infraNamespace: string; + publicUrl: string; + source: string; + sourceRef: string; + upstreamSourceRef: string; +} + +export interface AdminSecretsSummaryView { + contractVersion: string; + status: string; + generatedAt: string; + valuesPrinted: boolean; + secretMaterialStored: boolean; + target: AdminSecretsTargetView; + sourcePath: string; + sourcePresent: boolean; + counts: { externalSecrets: number; ready: number; pending: number; errors: number; events: number }; + eso: { controller: string; webhook: string; certController: string }; + stores: AdminSecretsStoreView[]; + consumerRollout: { total: number; ready: number; pending: number; revision: string }; + evidence: { source: string; issue: string; command: string; validatedAt: string; fingerprint: string; valuesPrinted: boolean }; +} + +export interface AdminSecretsStoreView { + kind: string; + name: string; + namespace: string; + scope: string; + status: string; + reason: string; +} + +export interface AdminSecretsRow { + id: string; + name: string; + namespace: string; + status: string; + ready: boolean; + reason: string; + refreshInterval: string; + lastRefreshTime: string; + targetNode: string; + targetLane: string; + targetSecretName: string; + storeText: string; + keySummary: string; + fingerprint: string; + consumerSummary: string; + valuesPrinted: boolean; + secretMaterialStored: boolean; + raw: AdminSecretsExternalSecret; +} + +export interface AdminSecretsDetailView extends AdminSecretsRow { + data: Array<{ targetKey: string; remotePath: string; property: string; version: string; fingerprint: string }>; + targetKeys: Array<{ key: string; presence: string; fingerprint: string }>; + consumers: Array<{ serviceId: string; kind: string; injectAs: string; envName: string; status: string; rollout: string; revision: string }>; + provenance: Array<{ label: string; value: string }>; +} + +export interface AdminSecretsEventView { + id: string; + status: string; + reason: string; + namespace: string; + resource: string; + message: string; + observedAt: string; + source: string; + traceId: string; + jobId: string; + valuesPrinted: boolean; +} + +export function normalizeAdminSecretsSummary(payload: AdminSecretsSummaryResponse | null | undefined): AdminSecretsSummaryView { + const summary = asRecord(payload?.summary); + const counts = asRecord(summary?.counts); + const eso = asRecord(summary?.eso); + const consumerRollout = asRecord(summary?.consumerRollout); + const evidence = asRecord(summary?.latestEvidence); + const source = asRecord(payload?.source); + return { + contractVersion: nonEmptyString(payload?.contractVersion) ?? "hwlab-admin-secrets-v1", + status: nonEmptyString(summary?.status) ?? nonEmptyString(payload?.status) ?? "Unknown", + generatedAt: nonEmptyString(payload?.generatedAt) ?? "", + valuesPrinted: payload?.valuesPrinted === false ? false : true, + secretMaterialStored: payload?.secretMaterialStored === false ? false : true, + target: normalizeTarget(payload?.target), + sourcePath: nonEmptyString(source?.path) ?? "-", + sourcePresent: source?.present === true, + counts: { + externalSecrets: numberValue(counts?.externalSecrets), + ready: numberValue(counts?.ready), + pending: numberValue(counts?.pending), + errors: numberValue(counts?.errors), + events: numberValue(counts?.events) + }, + eso: { + controller: nonEmptyString(eso?.controller) ?? "Unknown", + webhook: nonEmptyString(eso?.webhook) ?? "Unknown", + certController: nonEmptyString(eso?.certController) ?? "Unknown" + }, + stores: Array.isArray(summary?.stores) ? summary.stores.map((item) => normalizeStore(item)).filter(Boolean) : [], + consumerRollout: { + total: numberValue(consumerRollout?.total), + ready: numberValue(consumerRollout?.ready), + pending: numberValue(consumerRollout?.pending), + revision: nonEmptyString(consumerRollout?.revision) ?? "-" + }, + evidence: { + source: nonEmptyString(evidence?.source) ?? "-", + issue: nonEmptyString(evidence?.issue) ?? "-", + command: nonEmptyString(evidence?.command) ?? "-", + validatedAt: nonEmptyString(evidence?.validatedAt) ?? "", + fingerprint: nonEmptyString(evidence?.fingerprint) ?? "-", + valuesPrinted: evidence?.valuesPrinted === false ? false : true + } + }; +} + +export function normalizeAdminSecretsRows(items: AdminSecretsExternalSecret[] | null | undefined): AdminSecretsRow[] { + return Array.isArray(items) ? items.map((item) => normalizeRow(item)).sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`)) : []; +} + +export function normalizeAdminSecretsDetail(item: AdminSecretsExternalSecret | null | undefined): AdminSecretsDetailView | null { + if (!item) return null; + const row = normalizeRow(item); + const targetSecret = asRecord(item.targetSecret); + const provenance = asRecord(item.provenance); + return { + ...row, + data: Array.isArray(item.data) ? item.data.map((entry) => { + const remoteRef = asRecord(entry.remoteRef); + return { + targetKey: nonEmptyString(entry.targetKey) ?? "-", + remotePath: nonEmptyString(remoteRef?.path) ?? "-", + property: nonEmptyString(remoteRef?.property) ?? "-", + version: nonEmptyString(remoteRef?.version) ?? "-", + fingerprint: nonEmptyString(remoteRef?.fingerprint) ?? "-" + }; + }) : [], + targetKeys: Array.isArray(targetSecret?.keys) ? targetSecret.keys.map((entry) => ({ + key: nonEmptyString(entry.key) ?? "-", + presence: nonEmptyString(entry.presence) ?? "unknown", + fingerprint: nonEmptyString(entry.fingerprint) ?? "-" + })) : [], + consumers: Array.isArray(item.consumers) ? item.consumers.map((entry) => ({ + serviceId: nonEmptyString(entry.serviceId) ?? "-", + kind: nonEmptyString(entry.kind) ?? "-", + injectAs: nonEmptyString(entry.injectAs) ?? "-", + envName: nonEmptyString(entry.envName) ?? "-", + status: nonEmptyString(entry.status) ?? "Unknown", + rollout: nonEmptyString(entry.rollout) ?? "Unknown", + revision: nonEmptyString(entry.revision) ?? "-" + })) : [], + provenance: [ + fact("configSource", provenance?.configSource), + fact("statusSource", provenance?.statusSource), + fact("issue", provenance?.issue), + fact("command", provenance?.command), + fact("validatedAt", provenance?.validatedAt) + ].filter((item): item is { label: string; value: string } => Boolean(item)) + }; +} + +export function normalizeAdminSecretsEvents(items: AdminSecretsEvent[] | null | undefined): AdminSecretsEventView[] { + if (!Array.isArray(items)) return []; + return items.map((item, index) => { + const resourceKind = nonEmptyString(item.resourceKind) ?? "SecretPlane"; + const resourceName = nonEmptyString(item.resourceName) ?? "-"; + return { + id: `${nonEmptyString(item.observedAt) ?? "event"}:${resourceKind}:${resourceName}:${index}`, + status: nonEmptyString(item.status) ?? "Unknown", + reason: nonEmptyString(item.reason) ?? "-", + namespace: nonEmptyString(item.namespace) ?? "-", + resource: `${resourceKind}/${resourceName}`, + message: nonEmptyString(item.message) ?? "-", + observedAt: nonEmptyString(item.observedAt) ?? "", + source: nonEmptyString(item.source) ?? "-", + traceId: nonEmptyString(item.traceId) ?? "-", + jobId: nonEmptyString(item.jobId) ?? "-", + valuesPrinted: item.valuesPrinted === false ? false : true + }; + }); +} + +function normalizeRow(item: AdminSecretsExternalSecret): AdminSecretsRow { + const target = normalizeTarget(item.target); + const store = normalizeStore(item.store); + const targetSecret = asRecord(item.targetSecret); + const keys = Array.isArray(targetSecret?.keys) ? targetSecret.keys : []; + const consumers = Array.isArray(item.consumers) ? item.consumers : []; + return { + id: nonEmptyString(item.id) ?? `${nonEmptyString(item.namespace) ?? "-"}:${nonEmptyString(item.name) ?? "-"}`, + name: nonEmptyString(item.name) ?? "-", + namespace: nonEmptyString(item.namespace) ?? target.runtimeNamespace, + status: nonEmptyString(item.status) ?? "Unknown", + ready: item.ready === true, + reason: nonEmptyString(item.reason) ?? "-", + refreshInterval: nonEmptyString(item.refreshInterval) ?? "-", + lastRefreshTime: nonEmptyString(item.lastRefreshTime) ?? "", + targetNode: target.node, + targetLane: target.lane, + targetSecretName: nonEmptyString(targetSecret?.name) ?? "-", + storeText: `${store.kind}/${store.name}`, + keySummary: keys.map((entry) => `${nonEmptyString(entry.key) ?? "-"}:${nonEmptyString(entry.presence) ?? "unknown"}`).join(", ") || "-", + fingerprint: nonEmptyString(targetSecret?.fingerprint) ?? "-", + consumerSummary: consumers.map((entry) => `${nonEmptyString(entry.serviceId) ?? "-"}:${nonEmptyString(entry.rollout) ?? "Unknown"}`).join(", ") || "-", + valuesPrinted: item.valuesPrinted === false ? false : true, + secretMaterialStored: item.secretMaterialStored === false ? false : true, + raw: item + }; +} + +function normalizeTarget(value: AdminSecretsTarget | undefined): AdminSecretsTargetView { + return { + node: nonEmptyString(value?.node) ?? "-", + lane: nonEmptyString(value?.lane) ?? "-", + runtimeNamespace: nonEmptyString(value?.runtimeNamespace) ?? "-", + infraNamespace: nonEmptyString(value?.infraNamespace) ?? "-", + publicUrl: nonEmptyString(value?.publicUrl) ?? "-", + source: nonEmptyString(value?.source) ?? "-", + sourceRef: nonEmptyString(value?.sourceRef) ?? "-", + upstreamSourceRef: nonEmptyString(value?.upstreamSourceRef) ?? "-" + }; +} + +function normalizeStore(value: AdminSecretsStore | unknown): AdminSecretsStoreView { + const item = asRecord(value); + return { + kind: nonEmptyString(item?.kind) ?? "-", + name: nonEmptyString(item?.name) ?? "-", + namespace: nonEmptyString(item?.namespace) ?? "-", + scope: nonEmptyString(item?.scope) ?? "-", + status: nonEmptyString(item?.status) ?? "Unknown", + reason: nonEmptyString(item?.reason) ?? "-" + }; +} + +function fact(label: string, value: unknown): { label: string; value: string } | null { + const text = nonEmptyString(value); + return text ? { label, value: text } : null; +} + +function numberValue(value: unknown): number { + const number = Number(value ?? 0); + return Number.isFinite(number) ? number : 0; +} diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 5b16f7ef..1c32dace 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2609,6 +2609,150 @@ font-size: 12px; } +.secrets-admin-page code { + overflow-wrap: anywhere; + color: #0f766e; + font-size: 12px; +} + +.secrets-summary-grid, +.secrets-readiness-grid, +.secrets-detail-grid { + display: grid; + min-width: 0; + gap: 12px; +} + +.secrets-summary-grid, +.secrets-readiness-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.secrets-detail-grid { + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.75fr); +} + +.secrets-target-panel, +.secrets-contract-panel { + display: grid; + min-width: 0; + gap: 12px; + padding: 14px; +} + +.secrets-fact-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.secrets-fact-grid div { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 10px; +} + +.secrets-fact-grid dt, +.secrets-fact-grid dd { + min-width: 0; + margin: 0; +} + +.secrets-fact-grid dt { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.secrets-fact-grid dd { + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; + font-weight: 750; +} + +.secrets-subsection { + display: grid; + min-width: 0; + gap: 8px; +} + +.secrets-subsection h3 { + margin: 0; + color: #334155; + font-size: 13px; +} + +.secrets-chip-list { + display: grid; + min-width: 0; + gap: 8px; +} + +.secrets-chip { + display: grid; + min-width: 0; + gap: 3px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 9px 10px; +} + +.secrets-chip strong, +.secrets-chip small { + min-width: 0; + overflow-wrap: anywhere; +} + +.secrets-chip small { + color: #64748b; + font-size: 12px; +} + +.secrets-event-list { + display: grid; + max-height: 560px; + min-width: 0; + gap: 8px; + margin: 0; + overflow: auto; + padding: 0; + list-style: none; +} + +.secrets-event-list li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 10px; +} + +.secrets-event-list strong, +.secrets-event-list p, +.secrets-event-list small { + min-width: 0; + overflow-wrap: anywhere; +} + +.secrets-event-list p { + margin: 3px 0; + color: #334155; + font-size: 13px; +} + +.secrets-event-list small { + color: #64748b; + font-size: 12px; +} + .toast-host { position: fixed; right: 16px; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index de22afb4..bb07a49b 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -386,6 +386,39 @@ export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string; export interface CaseRunAggregateResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; sha256?: string; summary?: Record | null; evidence?: Record | null; run?: Record | null; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown } +export type AdminSecretStatus = "SecretLeakBlocked" | "StoreUnavailable" | "SyncError" | "TargetMissing" | "RolloutPending" | "ValidationFailed" | "Ready" | "Unknown" | string; +export interface AdminSecretsTarget { node?: string; lane?: string; runtimeNamespace?: string; infraNamespace?: string; publicUrl?: string; source?: string; sourceRef?: string; upstreamSourceRef?: string; [key: string]: unknown } +export interface AdminSecretsStore { kind?: string; name?: string; namespace?: string; scope?: string; status?: AdminSecretStatus; reason?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsTargetKey { key?: string; presence?: string; fingerprint?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsRemoteRef { path?: string | null; property?: string | null; version?: string | null; fingerprint?: string | null; redacted?: boolean; [key: string]: unknown } +export interface AdminSecretsDataMapping { targetKey?: string; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown } +export interface AdminSecretsConsumer { serviceId?: string; kind?: string; injectAs?: string; envName?: string | null; status?: AdminSecretStatus; rollout?: string; revision?: string | null; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsExternalSecret { + id?: string; + name?: string; + namespace?: string; + target?: AdminSecretsTarget; + store?: AdminSecretsStore; + status?: AdminSecretStatus; + ready?: boolean; + reason?: string; + message?: string; + refreshInterval?: string; + createdAt?: string | null; + lastRefreshTime?: string | null; + targetSecret?: { name?: string; namespace?: string; keys?: AdminSecretsTargetKey[]; resourceVersion?: string | null; fingerprint?: string | null; valuesPrinted?: boolean; [key: string]: unknown }; + data?: AdminSecretsDataMapping[]; + consumers?: AdminSecretsConsumer[]; + provenance?: Record; + valuesPrinted?: boolean; + secretMaterialStored?: boolean; + [key: string]: unknown; +} +export interface AdminSecretsEvent { status?: AdminSecretStatus; reason?: string; namespace?: string; resourceKind?: string; resourceName?: string; message?: string; observedAt?: string; traceId?: string | null; jobId?: string | null; source?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; source?: Record; summary?: Record; externalSecrets?: AdminSecretsExternalSecret[]; events?: AdminSecretsEvent[]; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } +export interface AdminSecretsListResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; items?: AdminSecretsExternalSecret[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } +export interface AdminSecretsDetailResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; item?: AdminSecretsExternalSecret; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } +export interface AdminSecretsEventsResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; events?: AdminSecretsEvent[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } export interface GateDiagnosticRow extends Record { category?: string; check?: string; diff --git a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue new file mode 100644 index 00000000..b2b45796 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue @@ -0,0 +1,247 @@ + + +