Merge pull request #2303 from pikasTech/feat/2298-eso-plan-wizard
feat: 实现 ESO ExternalSecret plan 向导
This commit is contained in:
@@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { sendJson } from "./server-http-utils.ts";
|
||||
|
||||
const CONTRACT_VERSION = "hwlab-admin-secrets-v1";
|
||||
const PLAN_PERMISSION = "admin.secrets.plan";
|
||||
const MAX_PLAN_BODY_BYTES = 32 * 1024;
|
||||
const DEFAULT_CONFIG_RELATIVE_PATH = "config/hwlab-v03/secrets.yaml";
|
||||
const DEFAULT_TARGET = Object.freeze({
|
||||
node: "JD01",
|
||||
@@ -29,12 +31,25 @@ const STATUS_PRIORITY = Object.freeze([
|
||||
"Ready",
|
||||
"Unknown"
|
||||
]);
|
||||
const PLAN_FAILURE_PRIORITY = Object.freeze([
|
||||
"rbac_denied",
|
||||
"leak_blocked",
|
||||
"store_scope_violation",
|
||||
"store_unavailable",
|
||||
"target_collision",
|
||||
"sync_risk",
|
||||
"validation_failed"
|
||||
]);
|
||||
const KUBERNETES_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u;
|
||||
const REF_PATH_RE = /^[A-Za-z0-9._/-]+$/u;
|
||||
const REF_KEY_RE = /^[A-Za-z0-9._-]+$/u;
|
||||
|
||||
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);
|
||||
const planRoute = request.method === "POST" && url.pathname === "/v1/admin/secrets/external-secrets/plan";
|
||||
const auth = await authenticateAdmin(request, options, { requireAdmin: !planRoute });
|
||||
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 });
|
||||
@@ -46,6 +61,13 @@ export async function handleAdminSecretsHttp(request, response, url, options = {
|
||||
if (request.method === "GET" && url.pathname === "/v1/admin/secrets/external-secrets") {
|
||||
return sendJson(response, 200, externalSecretsListPayload(readModel.payload, url));
|
||||
}
|
||||
if (planRoute) {
|
||||
const permission = adminSecretsPlanPermission(auth.actor);
|
||||
if (!permission.allowed) return sendJson(response, 403, planPermissionDeniedPayload(readModel.payload, permission));
|
||||
const body = await readJsonRequestBody(request, MAX_PLAN_BODY_BYTES);
|
||||
const plan = buildAdminSecretsPlan(readModel.payload, body, auth);
|
||||
return sendJson(response, plan.httpStatus ?? 200, plan);
|
||||
}
|
||||
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]));
|
||||
@@ -83,6 +105,13 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) {
|
||||
secretMaterialStored: false,
|
||||
generatedAt,
|
||||
target,
|
||||
permissions: {
|
||||
[PLAN_PERMISSION]: true,
|
||||
plan: true,
|
||||
apply: false,
|
||||
valuesPrinted: false,
|
||||
secretMaterialStored: false
|
||||
},
|
||||
source: {
|
||||
kind: "yaml",
|
||||
path: sourceRelativePath,
|
||||
@@ -142,6 +171,392 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAdminSecretsPlan(payload, requestBody = {}, auth = {}) {
|
||||
const generatedAt = new Date().toISOString();
|
||||
const permission = adminSecretsPlanPermission(auth.actor);
|
||||
if (!permission.allowed) return planPermissionDeniedPayload(payload, permission, generatedAt);
|
||||
|
||||
const leak = findPlanSecretMaterial(requestBody);
|
||||
if (leak) {
|
||||
return {
|
||||
...basePlanPayload(payload, generatedAt, permission),
|
||||
ok: false,
|
||||
status: "failed",
|
||||
failureKind: "leak_blocked",
|
||||
checks: [planCheck("leak-block", "Secret material blocked", "failed", "leak_blocked", "Plan request included a field that can carry secret material.")],
|
||||
risks: [planRisk("leak_blocked", "blocked", "Plan request was rejected before planning because it contained a disallowed secret material field.")],
|
||||
error: {
|
||||
code: "SecretLeakBlocked",
|
||||
message: "ExternalSecret plan requests must not include secret values.",
|
||||
layer: "admin-secrets-plan",
|
||||
retryable: false,
|
||||
field: leak.path,
|
||||
valuesPrinted: false
|
||||
},
|
||||
httpStatus: 422
|
||||
};
|
||||
}
|
||||
|
||||
const input = record(requestBody) ?? {};
|
||||
const activeTarget = payload.target ?? DEFAULT_TARGET;
|
||||
const targetInput = record(input.target) ?? {};
|
||||
const target = {
|
||||
node: (text(targetInput.node) || text(activeTarget.node) || DEFAULT_TARGET.node).toUpperCase(),
|
||||
lane: text(targetInput.lane) || text(activeTarget.lane) || DEFAULT_TARGET.lane,
|
||||
namespace: text(targetInput.namespace) || text(targetInput.runtimeNamespace) || text(activeTarget.runtimeNamespace) || DEFAULT_TARGET.runtimeNamespace
|
||||
};
|
||||
const mode = normalizePlanMode(input.mode);
|
||||
const externalSecretName = text(input.externalSecretName) || text(input.name);
|
||||
const targetSecretName = text(input.targetSecretName) || externalSecretName;
|
||||
const refreshInterval = text(input.refreshInterval) || "1m";
|
||||
const store = normalizePlanStore(input.store, payload, target);
|
||||
const data = normalizePlanData(input.data);
|
||||
const consumerRollout = normalizePlanConsumerRollout(input.consumerRollout);
|
||||
const existingExternalSecret = payload.externalSecrets.find((item) => item.namespace === target.namespace && item.name === externalSecretName) ?? null;
|
||||
const targetSecretCollision = payload.externalSecrets.find((item) => item.namespace === target.namespace && text(record(item.targetSecret)?.name) === targetSecretName) ?? null;
|
||||
const affectedConsumers = affectedPlanConsumers(existingExternalSecret ?? targetSecretCollision);
|
||||
const checks = [];
|
||||
const risks = [];
|
||||
const failures = new Set();
|
||||
|
||||
if (!externalSecretName || !KUBERNETES_NAME_RE.test(externalSecretName) || externalSecretName.length > 63) {
|
||||
checks.push(planCheck("external-secret-name", "ExternalSecret name", "failed", "validation_failed", "Name must be a Kubernetes DNS label with at most 63 characters."));
|
||||
failures.add("validation_failed");
|
||||
} else {
|
||||
checks.push(planCheck("external-secret-name", "ExternalSecret name", "passed", "ok", externalSecretName));
|
||||
}
|
||||
|
||||
if (!targetSecretName || !KUBERNETES_NAME_RE.test(targetSecretName) || targetSecretName.length > 63) {
|
||||
checks.push(planCheck("target-secret-name", "Target Secret name", "failed", "validation_failed", "Target Secret name must be a Kubernetes DNS label with at most 63 characters."));
|
||||
failures.add("validation_failed");
|
||||
} else {
|
||||
checks.push(planCheck("target-secret-name", "Target Secret name", "passed", "ok", targetSecretName));
|
||||
}
|
||||
|
||||
if (target.node !== text(activeTarget.node).toUpperCase() || target.lane !== text(activeTarget.lane) || target.namespace !== text(activeTarget.runtimeNamespace)) {
|
||||
checks.push(planCheck("active-target", "Active target", "failed", "validation_failed", "Plan target must match the API active target."));
|
||||
failures.add("validation_failed");
|
||||
} else {
|
||||
checks.push(planCheck("active-target", "Active target", "passed", "ok", `${target.node}/${target.lane}/${target.namespace}`));
|
||||
}
|
||||
|
||||
const availableStore = findPlanStore(payload, store);
|
||||
if (store.kind === "SecretStore" && store.namespace !== target.namespace) {
|
||||
checks.push(planCheck("store-scope", "Store namespace scope", "failed", "store_scope_violation", "Namespace-scoped SecretStore cannot plan for a different namespace."));
|
||||
risks.push(planRisk("store_scope_violation", "blocked", "Namespace-scoped Store is limited to the target namespace."));
|
||||
failures.add("store_scope_violation");
|
||||
} else {
|
||||
checks.push(planCheck("store-scope", "Store namespace scope", "passed", "ok", store.kind === "ClusterSecretStore" ? `cluster store targets ${target.namespace}` : store.namespace));
|
||||
}
|
||||
if (!availableStore) {
|
||||
checks.push(planCheck("store-available", "Store availability", "failed", "store_unavailable", `${store.kind}/${store.name} was not found in the active read model.`));
|
||||
risks.push(planRisk("store_unavailable", "blocked", "Plan cannot confirm the requested Store from the active target."));
|
||||
failures.add("store_unavailable");
|
||||
} else {
|
||||
checks.push(planCheck("store-available", "Store availability", "passed", "ok", `${store.kind}/${store.name}`));
|
||||
}
|
||||
|
||||
if (!/^\d+[smhd]$/u.test(refreshInterval)) {
|
||||
checks.push(planCheck("refresh-interval", "Refresh interval", "failed", "validation_failed", "Refresh interval must use s/m/h/d suffix."));
|
||||
failures.add("validation_failed");
|
||||
} else {
|
||||
checks.push(planCheck("refresh-interval", "Refresh interval", "passed", "ok", refreshInterval));
|
||||
}
|
||||
|
||||
for (const failure of validatePlanData(data, checks, risks)) failures.add(failure);
|
||||
|
||||
if (mode === "create" && (existingExternalSecret || targetSecretCollision)) {
|
||||
const collisionName = existingExternalSecret?.name ?? text(record(targetSecretCollision?.targetSecret)?.name) ?? targetSecretName;
|
||||
checks.push(planCheck("target-collision", "Target collision", "failed", "target_collision", `${target.namespace}/${collisionName} already exists in the read model.`));
|
||||
risks.push(planRisk("target_collision", "blocked", "Create plan would collide with an existing ExternalSecret or target Secret."));
|
||||
failures.add("target_collision");
|
||||
} else {
|
||||
checks.push(planCheck("target-collision", "Target collision", "passed", "ok", mode === "edit" ? "edit mode permits matching object" : "no collision detected"));
|
||||
}
|
||||
|
||||
checks.push(planCheck("dry-run", "Dry-run boundary", "passed", "ok", "No Kubernetes, Vault, ESO, or GitOps writes are performed."));
|
||||
checks.push(planCheck("secret-values", "Secret value boundary", "passed", "ok", "Plan request and response do not include secret values."));
|
||||
risks.push(planRisk("dry_run_only", "info", "This plan is read-only and cannot apply, rotate, or roll back a Secret."));
|
||||
if (consumerRollout.enabled && affectedConsumers.length === 0) risks.push(planRisk("consumer_rollout_marker", "info", "Consumer rollout marker is recorded as intent only; no consumer was resolved from the read model."));
|
||||
|
||||
const failureKind = firstFailureKind(failures);
|
||||
const ok = !failureKind;
|
||||
return {
|
||||
...basePlanPayload(payload, generatedAt, permission),
|
||||
ok,
|
||||
status: ok ? "planned" : "failed",
|
||||
planId: `esp_${sha256Text(stableJson({ target, mode, externalSecretName, targetSecretName, store, data, refreshInterval, consumerRollout })).slice(0, 16)}`,
|
||||
failureKind: failureKind || null,
|
||||
checks,
|
||||
risks,
|
||||
plan: {
|
||||
operation: mode === "edit" ? "edit-external-secret-dry-run" : "create-external-secret-dry-run",
|
||||
mode,
|
||||
dryRun: true,
|
||||
writes: false,
|
||||
target,
|
||||
externalSecret: {
|
||||
name: externalSecretName,
|
||||
namespace: target.namespace,
|
||||
refreshInterval
|
||||
},
|
||||
targetSecret: {
|
||||
name: targetSecretName,
|
||||
namespace: target.namespace,
|
||||
presence: targetSecretCollision ? "present" : "not-present",
|
||||
valuesPrinted: false
|
||||
},
|
||||
store,
|
||||
data: data.map((item) => ({
|
||||
targetKey: item.targetKey,
|
||||
sourceRef: item.sourceRef,
|
||||
presence: "not-read-dry-run",
|
||||
referenceFingerprint: item.referenceFingerprint,
|
||||
valuesPrinted: false
|
||||
})),
|
||||
affectedConsumers,
|
||||
consumerRollout,
|
||||
valuesPrinted: false,
|
||||
secretMaterialStored: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function basePlanPayload(payload, generatedAt, permission) {
|
||||
return {
|
||||
contractVersion: CONTRACT_VERSION,
|
||||
generatedAt,
|
||||
target: payload.target,
|
||||
permission,
|
||||
valuesPrinted: false,
|
||||
secretMaterialStored: false
|
||||
};
|
||||
}
|
||||
|
||||
function planPermissionDeniedPayload(payload, permission, generatedAt = new Date().toISOString()) {
|
||||
return {
|
||||
...basePlanPayload(payload, generatedAt, permission),
|
||||
ok: false,
|
||||
status: "failed",
|
||||
failureKind: "rbac_denied",
|
||||
checks: [planCheck("permission", PLAN_PERMISSION, "failed", "rbac_denied", `${PLAN_PERMISSION} is required.`)],
|
||||
risks: [planRisk("rbac_denied", "blocked", "Current actor is not allowed to plan ExternalSecret changes.")],
|
||||
error: {
|
||||
code: "admin_secrets_plan_required",
|
||||
message: `${PLAN_PERMISSION} permission is required.`,
|
||||
layer: "admin-secrets-plan",
|
||||
retryable: false,
|
||||
valuesPrinted: false
|
||||
},
|
||||
httpStatus: 403
|
||||
};
|
||||
}
|
||||
|
||||
function adminSecretsPlanPermission(actor) {
|
||||
const role = text(actor?.role);
|
||||
const permissions = permissionSetFromActor(actor);
|
||||
if (role === "admin") return { id: PLAN_PERMISSION, allowed: true, source: "admin-role", valuesPrinted: false };
|
||||
if (permissions.has(PLAN_PERMISSION)) return { id: PLAN_PERMISSION, allowed: true, source: "actor-permission", valuesPrinted: false };
|
||||
return { id: PLAN_PERMISSION, allowed: false, source: role || "anonymous", valuesPrinted: false };
|
||||
}
|
||||
|
||||
function permissionSetFromActor(actor) {
|
||||
const output = new Set();
|
||||
for (const key of ["permissions", "capabilities"]) {
|
||||
const value = actor?.[key];
|
||||
if (Array.isArray(value)) for (const item of value) if (text(item)) output.add(text(item));
|
||||
if (record(value)) for (const [name, status] of Object.entries(value)) if (status === true || status === "available" || status === "allowed") output.add(name);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function readJsonRequestBody(request, limitBytes) {
|
||||
let raw = "";
|
||||
for await (const chunk of request) {
|
||||
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "");
|
||||
if (Buffer.byteLength(raw, "utf8") > limitBytes) throw Object.assign(new Error("Plan request body is too large"), { httpStatus: 413, code: "plan_payload_too_large" });
|
||||
}
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error("Plan request body must be valid JSON"), { httpStatus: 400, code: "plan_payload_parse_error", details: { reason: error.message } });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlanMode(value) {
|
||||
const mode = text(value).toLowerCase();
|
||||
return mode === "edit" ? "edit" : "create";
|
||||
}
|
||||
|
||||
function normalizePlanStore(value, payload, target) {
|
||||
const input = record(value) ?? {};
|
||||
const firstStore = record(payload.summary?.stores?.[0]) ?? {};
|
||||
const kind = text(input.kind) === "SecretStore" ? "SecretStore" : "ClusterSecretStore";
|
||||
const namespace = kind === "SecretStore" ? text(input.namespace) || target.namespace : text(input.namespace) || text(firstStore.namespace) || text(payload.target?.infraNamespace) || "platform-infra";
|
||||
return {
|
||||
kind,
|
||||
name: text(input.name) || text(firstStore.name) || "hwlab-secret-plane-vault-cluster",
|
||||
namespace,
|
||||
scope: kind === "SecretStore" ? "namespace" : "cluster",
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function findPlanStore(payload, store) {
|
||||
const stores = Array.isArray(payload.summary?.stores) ? payload.summary.stores : [];
|
||||
return stores.find((item) => {
|
||||
if (text(item.kind) !== store.kind) return false;
|
||||
if (text(item.name) !== store.name) return false;
|
||||
if (store.kind === "SecretStore" && text(item.namespace) !== store.namespace) return false;
|
||||
return true;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function normalizePlanData(value) {
|
||||
const items = Array.isArray(value) ? value : [value].filter(Boolean);
|
||||
return items.map((entry, index) => {
|
||||
const item = record(entry) ?? {};
|
||||
const sourceRef = record(item.sourceRef) ?? record(item.remoteRef) ?? {};
|
||||
const pathValue = text(sourceRef.path) || text(item.path) || text(item.remotePath);
|
||||
const property = text(sourceRef.property) || text(item.property);
|
||||
const version = text(sourceRef.version) || text(item.version) || null;
|
||||
const targetKey = text(item.targetKey);
|
||||
return {
|
||||
targetKey,
|
||||
sourceRef: {
|
||||
path: pathValue,
|
||||
property,
|
||||
version
|
||||
},
|
||||
referenceFingerprint: pathValue || property ? `sha256:${sha256Text(`${pathValue}:${property}:${version ?? ""}`).slice(0, 16)}` : null,
|
||||
index
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePlanConsumerRollout(value) {
|
||||
const input = record(value) ?? {};
|
||||
const enabled = input.enabled === true || text(input.intent) !== "";
|
||||
const intent = text(input.intent) || (enabled ? "rollout-marker" : "none");
|
||||
return {
|
||||
enabled,
|
||||
intent,
|
||||
marker: enabled ? "plan-only" : "none",
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlanData(data, checks, risks) {
|
||||
const failures = new Set();
|
||||
if (data.length === 0) {
|
||||
checks.push(planCheck("source-ref", "Source reference", "failed", "sync_risk", "At least one sourceRef mapping is required."));
|
||||
risks.push(planRisk("sync_risk", "blocked", "ESO sync cannot be planned without sourceRef path/property and target key."));
|
||||
failures.add("sync_risk");
|
||||
return failures;
|
||||
}
|
||||
for (const item of data) {
|
||||
const label = `data[${item.index}]`;
|
||||
if (!item.targetKey || !REF_KEY_RE.test(item.targetKey)) {
|
||||
checks.push(planCheck(`${label}.targetKey`, "Target key", "failed", "validation_failed", "Target key must be non-empty and contain only letters, digits, dot, dash, or underscore."));
|
||||
failures.add("validation_failed");
|
||||
}
|
||||
if (!item.sourceRef.path || !REF_PATH_RE.test(item.sourceRef.path)) {
|
||||
checks.push(planCheck(`${label}.sourceRef.path`, "Source path", "failed", "sync_risk", "sourceRef.path is required and must be a safe remote reference path."));
|
||||
risks.push(planRisk("sync_risk", "blocked", "Remote source path is missing or malformed."));
|
||||
failures.add("sync_risk");
|
||||
}
|
||||
if (!item.sourceRef.property || !REF_KEY_RE.test(item.sourceRef.property)) {
|
||||
checks.push(planCheck(`${label}.sourceRef.property`, "Source property", "failed", "sync_risk", "sourceRef.property is required and must be a safe property name."));
|
||||
risks.push(planRisk("sync_risk", "blocked", "Remote source property is missing or malformed."));
|
||||
failures.add("sync_risk");
|
||||
}
|
||||
if (item.sourceRef.version && !REF_KEY_RE.test(item.sourceRef.version)) {
|
||||
checks.push(planCheck(`${label}.sourceRef.version`, "Source version", "failed", "validation_failed", "sourceRef.version contains unsupported characters."));
|
||||
failures.add("validation_failed");
|
||||
}
|
||||
}
|
||||
if (failures.size === 0) checks.push(planCheck("source-ref", "Source reference", "passed", "ok", `${data.length} mapping(s)`));
|
||||
return failures;
|
||||
}
|
||||
|
||||
function affectedPlanConsumers(secret) {
|
||||
if (!secret || !Array.isArray(secret.consumers)) return [];
|
||||
return secret.consumers.map((item) => ({
|
||||
serviceId: text(item.serviceId) || "-",
|
||||
kind: text(item.kind) || "-",
|
||||
injectAs: text(item.injectAs) || "-",
|
||||
envName: text(item.envName) || null,
|
||||
rollout: text(item.rollout) || "Unknown",
|
||||
revision: text(item.revision) || null,
|
||||
valuesPrinted: false
|
||||
}));
|
||||
}
|
||||
|
||||
function firstFailureKind(failures) {
|
||||
for (const kind of PLAN_FAILURE_PRIORITY) if (failures.has(kind)) return kind;
|
||||
return "";
|
||||
}
|
||||
|
||||
function planCheck(id, label, status, code, detail) {
|
||||
return { id, label, status, code, detail, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function planRisk(code, severity, message) {
|
||||
return { code, severity, message, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function findPlanSecretMaterial(value, pathParts = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const found = findPlanSecretMaterial(value[index], [...pathParts, String(index)]);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
if (typeof value === "string" && looksLikeSecretMaterial(value) && !isPlanDescriptorPath(pathParts)) return { path: pathParts.join(".") || "$" };
|
||||
return null;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const nextPath = [...pathParts, key];
|
||||
if (isSecretValueKey(key) && typeof item === "string" && item.trim() && !isPlanDescriptorPath(nextPath)) return { path: nextPath.join(".") };
|
||||
const found = findPlanSecretMaterial(item, nextPath);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSecretValueKey(key) {
|
||||
return /^(value|raw|secretValue|decoded|base64|authorization|cookie|token|apiKey|password)$/u.test(key);
|
||||
}
|
||||
|
||||
function isPlanDescriptorPath(pathParts) {
|
||||
const key = pathParts[pathParts.length - 1];
|
||||
if (["targetKey", "property", "version", "path", "remotePath", "externalSecretName", "targetSecretName", "name", "namespace", "kind", "intent", "mode", "node", "lane", "refreshInterval"].includes(key)) return true;
|
||||
const joined = pathParts.join(".");
|
||||
return /\.(sourceRef|remoteRef)\.(path|property|version)$/u.test(joined);
|
||||
}
|
||||
|
||||
function looksLikeSecretMaterial(value) {
|
||||
const item = text(value);
|
||||
if (!item) return false;
|
||||
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(item)) return true;
|
||||
if (/^(hsk|ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{16,}$/u.test(item)) return true;
|
||||
if (/^[A-Za-z0-9+/]{48,}={0,2}$/u.test(item) && !item.includes("/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortJson(value));
|
||||
}
|
||||
|
||||
function sortJson(value) {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
function externalSecretsListPayload(payload, url) {
|
||||
const node = text(url.searchParams.get("node")).toUpperCase();
|
||||
const lane = text(url.searchParams.get("lane")).toLowerCase();
|
||||
@@ -199,12 +614,12 @@ function eventsPayload(payload, url) {
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateAdmin(request, options) {
|
||||
async function authenticateAdmin(request, options, { requireAdmin = true } = {}) {
|
||||
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);
|
||||
if (requireAdmin && auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403);
|
||||
return auth;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fetchJson } from "./client";
|
||||
import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types";
|
||||
import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsPlanRequest, AdminSecretsPlanResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types";
|
||||
|
||||
export const adminSecretsAPI = {
|
||||
summary: (): Promise<ApiResult<AdminSecretsSummaryResponse>> => fetchJson("/v1/admin/secrets/summary", { timeoutMs: 12000, timeoutName: "admin secrets summary" }),
|
||||
externalSecrets: (params: { node?: string; lane?: string; namespace?: string } = {}): Promise<ApiResult<AdminSecretsListResponse>> => fetchJson(`/v1/admin/secrets/external-secrets${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets list" }),
|
||||
detail: (namespace: string, name: string): Promise<ApiResult<AdminSecretsDetailResponse>> => fetchJson(`/v1/admin/secrets/external-secrets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, { timeoutMs: 12000, timeoutName: "admin secrets detail" }),
|
||||
events: (params: { namespace?: string; limit?: number } = {}): Promise<ApiResult<AdminSecretsEventsResponse>> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" })
|
||||
events: (params: { namespace?: string; limit?: number } = {}): Promise<ApiResult<AdminSecretsEventsResponse>> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }),
|
||||
plan: (payload: AdminSecretsPlanRequest): Promise<ApiResult<AdminSecretsPlanResponse>> => fetchJson("/v1/admin/secrets/external-secrets/plan", { method: "POST", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin secrets plan" })
|
||||
};
|
||||
|
||||
function query(params: Record<string, unknown>): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AdminSecretsSummaryView {
|
||||
generatedAt: string;
|
||||
valuesPrinted: boolean;
|
||||
secretMaterialStored: boolean;
|
||||
permissions: { plan: boolean; apply: boolean; valuesPrinted: boolean };
|
||||
target: AdminSecretsTargetView;
|
||||
sourcePath: string;
|
||||
sourcePresent: boolean;
|
||||
@@ -88,12 +89,18 @@ export function normalizeAdminSecretsSummary(payload: AdminSecretsSummaryRespons
|
||||
const consumerRollout = asRecord(summary?.consumerRollout);
|
||||
const evidence = asRecord(summary?.latestEvidence);
|
||||
const source = asRecord(payload?.source);
|
||||
const permissions = asRecord(payload?.permissions);
|
||||
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,
|
||||
permissions: {
|
||||
plan: permissions?.plan === true || permissions?.["admin.secrets.plan"] === true,
|
||||
apply: permissions?.apply === true,
|
||||
valuesPrinted: permissions?.valuesPrinted === true
|
||||
},
|
||||
target: normalizeTarget(payload?.target),
|
||||
sourcePath: nonEmptyString(source?.path) ?? "-",
|
||||
sourcePresent: source?.present === true,
|
||||
|
||||
@@ -2477,6 +2477,14 @@
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-pill[data-status="dry-run"],
|
||||
.status-pill[data-status="planned"],
|
||||
.status-pill[data-status="pending"] {
|
||||
border-color: #bae6fd;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -2532,6 +2540,7 @@
|
||||
|
||||
.form-grid select,
|
||||
.admin-form input,
|
||||
.admin-form select,
|
||||
.admin-form textarea {
|
||||
min-width: 0;
|
||||
border: 1px solid #cbd5e1;
|
||||
@@ -2713,6 +2722,126 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-panel {
|
||||
border-color: #bae6fd;
|
||||
background: #f8fcff;
|
||||
}
|
||||
|
||||
.secrets-plan-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.secrets-plan-steps li {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 8px 10px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.secrets-plan-steps strong {
|
||||
display: inline-grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.secrets-plan-form {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-fieldset {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-fieldset legend {
|
||||
padding: 0 4px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.secrets-plan-fieldset input[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.secrets-plan-note {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-toggle {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.secrets-plan-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.secrets-plan-result {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-result h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.secrets-plan-result-grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.secrets-plan-checks {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.secrets-event-list {
|
||||
display: grid;
|
||||
max-height: 560px;
|
||||
@@ -3496,7 +3625,12 @@
|
||||
.performance-contract-strip,
|
||||
.performance-collection-strip,
|
||||
.performance-chart-grid,
|
||||
.performance-analytics-grid {
|
||||
.performance-analytics-grid,
|
||||
.secrets-summary-grid,
|
||||
.secrets-readiness-grid,
|
||||
.secrets-detail-grid,
|
||||
.secrets-plan-fieldset,
|
||||
.secrets-plan-result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -3520,7 +3654,9 @@
|
||||
|
||||
.field-grid,
|
||||
.form-grid.two-col,
|
||||
.compact-form-grid {
|
||||
.compact-form-grid,
|
||||
.secrets-fact-grid,
|
||||
.secrets-plan-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +393,52 @@ export interface AdminSecretsTargetKey { key?: string; presence?: string; finger
|
||||
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 AdminSecretsPlanPermission { id?: string; allowed?: boolean; source?: string; valuesPrinted?: boolean; [key: string]: unknown }
|
||||
export interface AdminSecretsPlanCheck { id?: string; label?: string; status?: string; code?: string; detail?: string; valuesPrinted?: boolean; [key: string]: unknown }
|
||||
export interface AdminSecretsPlanRisk { code?: string; severity?: string; message?: string; valuesPrinted?: boolean; [key: string]: unknown }
|
||||
export interface AdminSecretsPlanRequest {
|
||||
mode?: "create" | "edit" | string;
|
||||
target?: { node?: string; lane?: string; namespace?: string; runtimeNamespace?: string; [key: string]: unknown };
|
||||
externalSecretName?: string;
|
||||
targetSecretName?: string;
|
||||
store?: { kind?: "SecretStore" | "ClusterSecretStore" | string; name?: string; namespace?: string; [key: string]: unknown };
|
||||
data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown }>;
|
||||
refreshInterval?: string;
|
||||
consumerRollout?: { enabled?: boolean; intent?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface AdminSecretsPlanResponse {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
contractVersion?: string;
|
||||
generatedAt?: string;
|
||||
target?: AdminSecretsTarget;
|
||||
permission?: AdminSecretsPlanPermission;
|
||||
planId?: string | null;
|
||||
failureKind?: string | null;
|
||||
checks?: AdminSecretsPlanCheck[];
|
||||
risks?: AdminSecretsPlanRisk[];
|
||||
plan?: {
|
||||
operation?: string;
|
||||
mode?: string;
|
||||
dryRun?: boolean;
|
||||
writes?: boolean;
|
||||
target?: { node?: string; lane?: string; namespace?: string; [key: string]: unknown };
|
||||
externalSecret?: { name?: string; namespace?: string; refreshInterval?: string; [key: string]: unknown };
|
||||
targetSecret?: { name?: string; namespace?: string; presence?: string; valuesPrinted?: boolean; [key: string]: unknown };
|
||||
store?: AdminSecretsStore;
|
||||
data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; presence?: string; referenceFingerprint?: string | null; valuesPrinted?: boolean; [key: string]: unknown }>;
|
||||
affectedConsumers?: AdminSecretsConsumer[];
|
||||
consumerRollout?: { enabled?: boolean; intent?: string; marker?: string; valuesPrinted?: boolean; [key: string]: unknown };
|
||||
valuesPrinted?: boolean;
|
||||
secretMaterialStored?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
error?: Record<string, unknown>;
|
||||
valuesPrinted?: boolean;
|
||||
secretMaterialStored?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface AdminSecretsExternalSecret {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -415,7 +461,7 @@ export interface AdminSecretsExternalSecret {
|
||||
[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<string, unknown>; summary?: Record<string, unknown>; externalSecrets?: AdminSecretsExternalSecret[]; events?: AdminSecretsEvent[]; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown }
|
||||
export interface AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; permissions?: Record<string, unknown>; source?: Record<string, unknown>; summary?: Record<string, unknown>; 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 }
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { adminSecretsAPI } from "@/api";
|
||||
import type { AdminSecretsPlanRequest, AdminSecretsPlanResponse } from "@/types";
|
||||
import type { AdminSecretsStoreView, AdminSecretsSummaryView } from "@/stores/admin-secrets-view";
|
||||
|
||||
const props = defineProps<{
|
||||
summary: AdminSecretsSummaryView;
|
||||
}>();
|
||||
|
||||
interface PlanForm {
|
||||
mode: "create" | "edit";
|
||||
externalSecretName: string;
|
||||
targetSecretName: string;
|
||||
storeKind: "ClusterSecretStore" | "SecretStore";
|
||||
storeName: string;
|
||||
storeNamespace: string;
|
||||
remotePath: string;
|
||||
property: string;
|
||||
version: string;
|
||||
targetKey: string;
|
||||
refreshInterval: string;
|
||||
consumerRolloutEnabled: boolean;
|
||||
consumerRolloutIntent: string;
|
||||
}
|
||||
|
||||
const defaultStore = computed(() => props.summary.stores[0] ?? { kind: "ClusterSecretStore", name: "hwlab-secret-plane-vault-cluster", namespace: props.summary.target.infraNamespace, scope: "cluster", status: "Unknown", reason: "" });
|
||||
const form = reactive<PlanForm>({
|
||||
mode: "create",
|
||||
externalSecretName: "",
|
||||
targetSecretName: "",
|
||||
storeKind: defaultStore.value.kind === "SecretStore" ? "SecretStore" : "ClusterSecretStore",
|
||||
storeName: defaultStore.value.name,
|
||||
storeNamespace: defaultStore.value.kind === "SecretStore" ? props.summary.target.runtimeNamespace : defaultStore.value.namespace,
|
||||
remotePath: "hwlab-secret-plane/example",
|
||||
property: "password",
|
||||
version: "",
|
||||
targetKey: "password",
|
||||
refreshInterval: "1m",
|
||||
consumerRolloutEnabled: true,
|
||||
consumerRolloutIntent: "rollout-marker"
|
||||
});
|
||||
|
||||
const attempted = ref(false);
|
||||
const planning = ref(false);
|
||||
const planResult = ref<AdminSecretsPlanResponse | null>(null);
|
||||
const planError = ref<string | null>(null);
|
||||
|
||||
const storeOptions = computed<AdminSecretsStoreView[]>(() => props.summary.stores);
|
||||
const fieldErrors = computed(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!safeName(form.externalSecretName)) errors.externalSecretName = "需要 DNS label 格式的 ExternalSecret 名称。";
|
||||
const targetSecret = form.targetSecretName.trim() || form.externalSecretName.trim();
|
||||
if (!safeName(targetSecret)) errors.targetSecretName = "需要 DNS label 格式的 target Secret 名称。";
|
||||
if (!form.storeName.trim()) errors.storeName = "需要选择或填写 Store 名称。";
|
||||
if (form.storeKind === "SecretStore" && form.storeNamespace.trim() !== props.summary.target.runtimeNamespace) errors.storeNamespace = "SecretStore 只能使用目标 namespace。";
|
||||
if (!/^[A-Za-z0-9._/-]+$/u.test(form.remotePath.trim())) errors.remotePath = "需要 sourceRef path。";
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(form.property.trim())) errors.property = "需要 sourceRef property。";
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(form.targetKey.trim())) errors.targetKey = "需要 target key。";
|
||||
if (!/^\d+[smhd]$/u.test(form.refreshInterval.trim())) errors.refreshInterval = "refresh interval 需要使用 s/m/h/d 后缀。";
|
||||
return errors;
|
||||
});
|
||||
const validationMessages = computed(() => Object.values(fieldErrors.value));
|
||||
const resultPlan = computed(() => planResult.value?.plan ?? null);
|
||||
const resultData = computed(() => Array.isArray(resultPlan.value?.data) ? resultPlan.value.data : []);
|
||||
const resultConsumers = computed(() => Array.isArray(resultPlan.value?.affectedConsumers) ? resultPlan.value.affectedConsumers : []);
|
||||
const resultChecks = computed(() => Array.isArray(planResult.value?.checks) ? planResult.value.checks : []);
|
||||
const resultRisks = computed(() => Array.isArray(planResult.value?.risks) ? planResult.value.risks : []);
|
||||
const resultTone = computed(() => planResult.value?.ok === false ? "failed" : planResult.value?.status === "planned" ? "ready" : "pending");
|
||||
const statusLabel = computed(() => {
|
||||
if (!planResult.value) return "not submitted";
|
||||
if (planResult.value.ok === false) return `failed: ${planResult.value.failureKind ?? "unknown"}`;
|
||||
return `${planResult.value.status ?? "planned"} / dry-run`;
|
||||
});
|
||||
|
||||
function safeName(value: string): boolean {
|
||||
const name = value.trim();
|
||||
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(name) && name.length <= 63;
|
||||
}
|
||||
|
||||
function syncStoreDefaults(): void {
|
||||
const match = storeOptions.value.find((store) => store.kind === form.storeKind) ?? null;
|
||||
form.storeName = match?.name || form.storeName;
|
||||
form.storeNamespace = form.storeKind === "SecretStore" ? props.summary.target.runtimeNamespace : match?.namespace || props.summary.target.infraNamespace;
|
||||
}
|
||||
|
||||
async function submitPlan(): Promise<void> {
|
||||
attempted.value = true;
|
||||
planError.value = null;
|
||||
if (validationMessages.value.length > 0) return;
|
||||
planning.value = true;
|
||||
const response = await adminSecretsAPI.plan(buildPayload());
|
||||
planResult.value = response.data;
|
||||
if (!response.ok) planError.value = response.error ?? `HTTP ${response.status}`;
|
||||
planning.value = false;
|
||||
}
|
||||
|
||||
function buildPayload(): AdminSecretsPlanRequest {
|
||||
return {
|
||||
mode: form.mode,
|
||||
target: {
|
||||
node: props.summary.target.node,
|
||||
lane: props.summary.target.lane,
|
||||
namespace: props.summary.target.runtimeNamespace
|
||||
},
|
||||
externalSecretName: form.externalSecretName.trim(),
|
||||
targetSecretName: form.targetSecretName.trim() || form.externalSecretName.trim(),
|
||||
store: {
|
||||
kind: form.storeKind,
|
||||
name: form.storeName.trim(),
|
||||
namespace: form.storeNamespace.trim()
|
||||
},
|
||||
data: [{
|
||||
targetKey: form.targetKey.trim(),
|
||||
sourceRef: {
|
||||
path: form.remotePath.trim(),
|
||||
property: form.property.trim(),
|
||||
version: form.version.trim() || null
|
||||
}
|
||||
}],
|
||||
refreshInterval: form.refreshInterval.trim(),
|
||||
consumerRollout: {
|
||||
enabled: form.consumerRolloutEnabled,
|
||||
intent: form.consumerRolloutEnabled ? form.consumerRolloutIntent.trim() || "rollout-marker" : "none"
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="data-panel admin-crud-panel secrets-plan-panel" data-testid="admin-secrets-plan-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>ExternalSecret Plan</h2>
|
||||
<small>{{ props.summary.target.node }} / {{ props.summary.target.lane }} / {{ props.summary.target.runtimeNamespace }}</small>
|
||||
</div>
|
||||
<span class="status-pill" data-status="dry-run">dry-run only</span>
|
||||
</header>
|
||||
|
||||
<ol class="secrets-plan-steps" aria-label="ExternalSecret plan steps">
|
||||
<li><strong>1</strong><span>Target</span></li>
|
||||
<li><strong>2</strong><span>Names</span></li>
|
||||
<li><strong>3</strong><span>Store</span></li>
|
||||
<li><strong>4</strong><span>SourceRef</span></li>
|
||||
<li><strong>5</strong><span>Result</span></li>
|
||||
</ol>
|
||||
|
||||
<form class="admin-form secrets-plan-form" @submit.prevent="submitPlan">
|
||||
<fieldset class="secrets-plan-fieldset">
|
||||
<legend>Target</legend>
|
||||
<label>node<input :value="props.summary.target.node" readonly data-testid="plan-node" /></label>
|
||||
<label>lane<input :value="props.summary.target.lane" readonly data-testid="plan-lane" /></label>
|
||||
<label>namespace<input :value="props.summary.target.runtimeNamespace" readonly data-testid="plan-namespace" /></label>
|
||||
<label>mode
|
||||
<select v-model="form.mode" data-testid="plan-mode">
|
||||
<option value="create">create</option>
|
||||
<option value="edit">edit</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="secrets-plan-fieldset">
|
||||
<legend>Names</legend>
|
||||
<label>ExternalSecret
|
||||
<input v-model.trim="form.externalSecretName" placeholder="example-secret" autocomplete="off" data-testid="plan-external-secret-name" />
|
||||
</label>
|
||||
<label>target Secret
|
||||
<input v-model.trim="form.targetSecretName" placeholder="defaults to ExternalSecret" autocomplete="off" data-testid="plan-target-secret-name" />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="secrets-plan-fieldset">
|
||||
<legend>Store</legend>
|
||||
<label>kind
|
||||
<select v-model="form.storeKind" data-testid="plan-store-kind" @change="syncStoreDefaults">
|
||||
<option value="ClusterSecretStore">ClusterSecretStore</option>
|
||||
<option value="SecretStore">SecretStore</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>name
|
||||
<input v-model.trim="form.storeName" list="admin-secrets-store-options" autocomplete="off" data-testid="plan-store-name" />
|
||||
<datalist id="admin-secrets-store-options">
|
||||
<option v-for="store in storeOptions" :key="`${store.kind}:${store.name}`" :value="store.name">{{ store.kind }}</option>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>store namespace
|
||||
<input v-model.trim="form.storeNamespace" :readonly="form.storeKind === 'ClusterSecretStore'" autocomplete="off" data-testid="plan-store-namespace" />
|
||||
</label>
|
||||
<p class="secrets-plan-note span-2">SecretStore must stay in target namespace; ClusterSecretStore plans against the selected target namespace.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="secrets-plan-fieldset">
|
||||
<legend>SourceRef</legend>
|
||||
<label>path
|
||||
<input v-model.trim="form.remotePath" autocomplete="off" data-testid="plan-remote-path" />
|
||||
</label>
|
||||
<label>property
|
||||
<input v-model.trim="form.property" autocomplete="off" data-testid="plan-remote-property" />
|
||||
</label>
|
||||
<label>version
|
||||
<input v-model.trim="form.version" placeholder="optional" autocomplete="off" data-testid="plan-remote-version" />
|
||||
</label>
|
||||
<label>target key
|
||||
<input v-model.trim="form.targetKey" autocomplete="off" data-testid="plan-target-key" />
|
||||
</label>
|
||||
<label>refresh interval
|
||||
<input v-model.trim="form.refreshInterval" autocomplete="off" data-testid="plan-refresh-interval" />
|
||||
</label>
|
||||
<label class="secrets-plan-toggle">
|
||||
<input v-model="form.consumerRolloutEnabled" type="checkbox" data-testid="plan-consumer-rollout" />
|
||||
<span>consumer rollout marker</span>
|
||||
</label>
|
||||
<label class="span-2">rollout intent
|
||||
<input v-model.trim="form.consumerRolloutIntent" :disabled="!form.consumerRolloutEnabled" autocomplete="off" data-testid="plan-consumer-rollout-intent" />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<section v-if="attempted && validationMessages.length > 0" class="form-error" data-testid="admin-secrets-plan-validation">
|
||||
<strong>字段校验未通过</strong>
|
||||
<ul>
|
||||
<li v-for="message in validationMessages" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="table-actions-inline">
|
||||
<button class="table-action" type="submit" :disabled="planning" data-testid="admin-secrets-plan-submit">{{ planning ? "Planning" : "Plan" }}</button>
|
||||
<span class="status-pill" data-status="dry-run">valuesPrinted=false</span>
|
||||
<span class="status-pill" data-status="dry-run">secretMaterialStored=false</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-if="planError" class="form-error" data-testid="admin-secrets-plan-error">{{ planError }}</section>
|
||||
|
||||
<section v-if="planResult" class="secrets-plan-result" data-testid="admin-secrets-plan-result">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h3>Plan Result</h3>
|
||||
<small>{{ planResult.planId || "no plan id" }}</small>
|
||||
</div>
|
||||
<span class="status-pill" :data-status="resultTone" data-testid="admin-secrets-plan-status">{{ statusLabel }}</span>
|
||||
</header>
|
||||
|
||||
<dl class="secrets-fact-grid">
|
||||
<div><dt>valuesPrinted</dt><dd>{{ String(planResult.valuesPrinted) }}</dd></div>
|
||||
<div><dt>secretMaterialStored</dt><dd>{{ String(planResult.secretMaterialStored) }}</dd></div>
|
||||
<div><dt>permission</dt><dd>{{ planResult.permission?.id }} / {{ String(planResult.permission?.allowed) }}</dd></div>
|
||||
<div><dt>operation</dt><dd>{{ resultPlan?.operation || "-" }}</dd></div>
|
||||
<div><dt>target</dt><dd>{{ resultPlan?.target?.node }} / {{ resultPlan?.target?.lane }} / {{ resultPlan?.target?.namespace }}</dd></div>
|
||||
<div><dt>store</dt><dd>{{ resultPlan?.store?.kind }} / {{ resultPlan?.store?.name }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="secrets-subsection">
|
||||
<h3>Mapping</h3>
|
||||
<div class="secrets-chip-list">
|
||||
<span v-for="item in resultData" :key="`${item.targetKey}:${item.referenceFingerprint}`" class="secrets-chip">
|
||||
<strong>{{ item.targetKey }} / {{ item.presence }}</strong>
|
||||
<small>{{ item.sourceRef?.path }} / {{ item.sourceRef?.property }} / {{ item.sourceRef?.version || "latest" }} / {{ item.referenceFingerprint }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="secrets-plan-result-grid">
|
||||
<section class="secrets-subsection">
|
||||
<h3>Consumers</h3>
|
||||
<div class="secrets-chip-list">
|
||||
<span v-if="resultConsumers.length === 0" class="secrets-chip"><strong>none</strong><small>{{ resultPlan?.consumerRollout?.marker || "none" }}</small></span>
|
||||
<span v-for="consumer in resultConsumers" :key="`${consumer.serviceId}:${consumer.envName}`" class="secrets-chip">
|
||||
<strong>{{ consumer.serviceId }}</strong>
|
||||
<small>{{ consumer.kind }} / {{ consumer.injectAs }} / {{ consumer.rollout }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="secrets-subsection">
|
||||
<h3>Risks</h3>
|
||||
<div class="secrets-chip-list">
|
||||
<span v-for="risk in resultRisks" :key="`${risk.code}:${risk.severity}`" class="secrets-chip">
|
||||
<strong>{{ risk.code }} / {{ risk.severity }}</strong>
|
||||
<small>{{ risk.message }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="secrets-subsection">
|
||||
<h3>Checks</h3>
|
||||
<div class="secrets-plan-checks">
|
||||
<span v-for="check in resultChecks" :key="check.id" class="status-pill" :data-status="check.status === 'failed' ? 'failed' : 'ready'" :title="check.detail">
|
||||
{{ check.label }}: {{ check.status }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { adminSecretsAPI } from "@/api";
|
||||
import AdminSecretsPlanPanel from "./AdminSecretsPlanPanel.vue";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import DataTable from "@/components/common/DataTable.vue";
|
||||
import type { DataTableColumn } from "@/components/common/DataTable.vue";
|
||||
@@ -32,8 +33,10 @@ const rows = ref<AdminSecretsRow[]>([]);
|
||||
const detail = ref<AdminSecretsDetailView | null>(null);
|
||||
const events = ref<AdminSecretsEventView[]>([]);
|
||||
const selectedKey = ref("");
|
||||
const planPanelOpen = ref(false);
|
||||
|
||||
const selectedRow = computed(() => rows.value.find((row) => rowKey(row) === selectedKey.value) ?? rows.value[0] ?? null);
|
||||
const canPlan = computed(() => summary.value?.permissions.plan === true);
|
||||
const targetFacts = computed(() => {
|
||||
const target = summary.value?.target;
|
||||
if (!target) return [];
|
||||
@@ -128,7 +131,10 @@ function displayDate(value: string): string {
|
||||
<section class="data-panel secrets-contract-panel">
|
||||
<header class="panel-header">
|
||||
<h2>{{ summary.contractVersion }}</h2>
|
||||
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
|
||||
<div class="table-actions-inline">
|
||||
<button v-if="canPlan" class="table-action" type="button" data-testid="admin-secrets-open-plan" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "收起 Plan" : "Plan" }}</button>
|
||||
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="secrets-fact-grid">
|
||||
<div><dt>valuesPrinted</dt><dd>{{ String(summary.valuesPrinted) }}</dd></div>
|
||||
@@ -160,6 +166,8 @@ function displayDate(value: string): string {
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<AdminSecretsPlanPanel v-if="canPlan && planPanelOpen" :summary="summary" />
|
||||
|
||||
<TablePageLayout title="ExternalSecrets" subtitle="ESO read model">
|
||||
<template #actions>
|
||||
<span class="status-pill" data-status="ready">{{ rows.length }} rows</span>
|
||||
|
||||
Reference in New Issue
Block a user