diff --git a/config/hwlab-v03/secrets.yaml b/config/hwlab-v03/secrets.yaml new file mode 100644 index 00000000..51aa71ca --- /dev/null +++ b/config/hwlab-v03/secrets.yaml @@ -0,0 +1,29 @@ +version: 1 +kind: hwlab-v03-secret-plane +metadata: + id: hwlab-v03-secret-plane + owner: HWLAB + spec: pikasTech/HWLAB#2234 + boundary: D601/v0.3 only +secretPlane: + enabled: true + issue: pikasTech/HWLAB#2234 + valuesPrinted: false + store: + kind: ClusterSecretStore + name: hwlab-secret-plane-vault-cluster + refreshInterval: 1m + secrets: + - id: hwlab-secret-plane-smoke + externalSecretName: hwlab-secret-plane-smoke + targetSecretName: hwlab-secret-plane-smoke + purpose: Low-risk D601/v0.3 smoke secret for platform-infra secret-plane integration. + data: + - targetKey: password + remoteRef: hwlab-secret-plane/poc + property: password + consumers: + - serviceId: hwlab-cloud-api + kind: Deployment + injectAs: env + envName: HWLAB_SECRET_PLANE_SMOKE diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index e684a663..912193d0 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -328,6 +328,7 @@ lanes: accessControl: usersRef: config/hwlab-access-control/users.d601-v03.yaml#spec.users navProfilesRef: config/hwlab-access-control/nav-profiles.yaml#spec.profiles + secretPlaneRef: config/hwlab-v03/secrets.yaml#secretPlane workbench: traceTimeline: autoExpandRunning: false @@ -766,6 +767,7 @@ lanes: HWLAB_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE: "100" HWLAB_WORKBENCH_EMPTY_SESSION_GC_JITTER_MS: "15000" HWLAB_WORKBENCH_EMPTY_SESSION_GC_FAILURE_BACKOFF_MS: "120000" + HWLAB_SECRET_PLANE_SMOKE: secretRef:hwlab-secret-plane-smoke/password configMapMounts: - name: hwpod-preinstalled-specs configMapName: hwlab-v03-hwpod-preinstalled-specs diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index b999349f..45851fe3 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -5521,10 +5521,88 @@ function runtimeConfigMapsManifest({ configMaps, namespace, labels, annotations }; } -function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false } = {}) { +async function runtimeSecretPlaneConfigForProfile(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const laneConfig = deploy?.lanes?.[profile]; + const secretPlane = laneConfig?.secretPlaneRef + ? await readConfigRef(laneConfig.secretPlaneRef, `${profile}.secretPlaneRef`) + : laneConfig?.secretPlane; + if (!secretPlane || typeof secretPlane !== "object" || Array.isArray(secretPlane)) return null; + if (secretPlane.enabled !== true) return null; + return cloneJson(secretPlane); +} + +function runtimeSecretPlaneManifest({ config, namespace, labels, annotations }) { + const store = config?.store; + assert.ok(store && typeof store === "object" && !Array.isArray(store), "secretPlane.store must be an object"); + assert.equal(store.kind, "ClusterSecretStore", "secretPlane.store.kind must be ClusterSecretStore for D601/v0.3"); + assert.ok(typeof store.name === "string" && store.name.trim(), "secretPlane.store.name must be set"); + const secrets = Array.isArray(config.secrets) ? config.secrets : []; + assert.ok(secrets.length > 0, "secretPlane.secrets must not be empty"); + return { + apiVersion: "v1", + kind: "List", + items: secrets.map((secret, index) => runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store })) + }; +} + +function runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store }) { + const externalSecretName = requiredSecretPlaneString(secret?.externalSecretName, `secretPlane.secrets[${index}].externalSecretName`); + const targetSecretName = requiredSecretPlaneString(secret?.targetSecretName, `secretPlane.secrets[${index}].targetSecretName`); + const data = Array.isArray(secret?.data) ? secret.data : []; + assert.ok(data.length > 0, `secretPlane.secrets[${index}].data must not be empty`); + const sourceRefs = data.map((item) => requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data.remoteRef`)); + return { + apiVersion: "external-secrets.io/v1", + kind: "ExternalSecret", + metadata: { + name: externalSecretName, + namespace, + labels: { + ...labels, + "app.kubernetes.io/name": externalSecretName, + "app.kubernetes.io/component": "external-secret", + "hwlab.pikastech.local/secret-plane": String(config.issue ?? "pikasTech/HWLAB#2234") + }, + annotations: { + ...annotations, + "hwlab.pikastech.local/source-ref": sourceRefs.join(","), + "hwlab.pikastech.local/values-printed": "false" + } + }, + spec: { + refreshInterval: requiredSecretPlaneString(config.refreshInterval, "secretPlane.refreshInterval"), + secretStoreRef: { + name: requiredSecretPlaneString(store.name, "secretPlane.store.name"), + kind: store.kind + }, + target: { + name: targetSecretName, + creationPolicy: "Owner" + }, + data: data.map((item, itemIndex) => ({ + secretKey: requiredSecretPlaneString(item?.targetKey, `secretPlane.secrets[${index}].data[${itemIndex}].targetKey`), + remoteRef: { + key: requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data[${itemIndex}].remoteRef`), + property: requiredSecretPlaneString(item?.property, `secretPlane.secrets[${index}].data[${itemIndex}].property`) + } + })) + } + }; +} + +function requiredSecretPlaneString(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + const trimmed = value.trim(); + assert.ok(trimmed, `${label} must not be empty`); + return trimmed; +} + +function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false } = {}) { const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"]; if (!isRuntimeLane(profile)) resources.splice(1, 0, "code-agent-codex-config.yaml"); if (runtimeConfigMaps) resources.splice(3, 0, "configmaps.yaml"); + if (externalSecrets) resources.splice(resources.indexOf("workloads.yaml"), 0, "external-secrets.yaml"); if (isRuntimeLane(profile)) { if (externalPostgres) resources.push("external-postgres.yaml"); else resources.push("postgres.yaml"); @@ -5666,6 +5744,7 @@ async function plannedFiles(args) { const externalPostgres = externalPostgresConfigForLane(deploy, profile); const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile); const runtimeConfigMaps = runtimeConfigMapsForProfile(deploy, profile); + const runtimeSecretPlane = await runtimeSecretPlaneConfigForProfile(deploy, profile); const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) }; const runtimeNamespace = cloneJson(namespaceTemplate); runtimeNamespace.metadata.name = namespace; @@ -5673,12 +5752,13 @@ async function plannedFiles(args) { annotate(runtimeNamespace.metadata, annotations); const runtimePath = runtimePathForProfile(profile); const endpoints = profileEndpoint(args, profile); - putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0 })); + putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0, externalSecrets: Boolean(runtimeSecretPlane) })); putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace); if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy })); putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile)); if (runtimeConfigMaps.length > 0) putJson(`${runtimePath}/configmaps.yaml`, runtimeConfigMapsManifest({ configMaps: runtimeConfigMaps, namespace, labels: profileLabels, annotations })); + if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations })); putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source })); diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 693fa9c2..54d4b9ec 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -566,6 +566,7 @@ test("v03 render keeps node identity as data instead of generated structure", as assert.ok(generatedPaths.includes("tekton-v03/pipeline.yaml")); assert.ok(generatedPaths.includes("runtime-v03/external-postgres.yaml")); assert.ok(generatedPaths.includes("runtime-v03/configmaps.yaml")); + assert.ok(generatedPaths.includes("runtime-v03/external-secrets.yaml")); const pipeline = await readFile(path.join(outDir, "tekton-v03", "pipeline.yaml"), "utf8"); assert.match(pipeline, /--external-service-report-dir \/workspace\/source\/service-results/u); assert.match(pipeline, /--ci-plan-path \/workspace\/source\/affected-services\.json/u); @@ -577,11 +578,20 @@ test("v03 render keeps node identity as data instead of generated structure", as const workloads = await readFile(path.join(outDir, "runtime-v03", "workloads.yaml"), "utf8"); const workloadsJson = JSON.parse(workloads); const configMapsJson = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "configmaps.yaml"), "utf8")); + const externalSecretsJson = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "external-secrets.yaml"), "utf8")); const configMapNames = (configMapsJson.items ?? []).map((item) => item.metadata?.name).sort(); assert.deepEqual(configMapNames, ["hwlab-v03-hwpod-preinstalled-specs", "hwlab-v03-project-management-bootstrap"]); + 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.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"); + assert.deepEqual(smokeExternalSecret.spec?.data, [{ secretKey: "password", remoteRef: { key: "hwlab-secret-plane/poc", property: "password" } }]); const cloudApi = (workloadsJson.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-cloud-api"); const cloudApiContainer = collectContainersFromItem(cloudApi).find((container) => container.name === "hwlab-cloud-api"); const cloudApiEnv = new Map((cloudApiContainer?.env ?? []).map((entry) => [entry.name, entry.value])); + const cloudApiEnvEntries = new Map((cloudApiContainer?.env ?? []).map((entry) => [entry.name, entry])); assert.equal(cloudApi?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/source-commit"], sourceRevision); assert.equal(cloudApi?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/artifact-source-commit"], sourceRevision); assert.equal(cloudApi?.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/source-commit"], sourceRevision); @@ -591,6 +601,7 @@ test("v03 render keeps node identity as data instead of generated structure", as assert.equal(cloudApiEnv.get("HWLAB_GITOPS_SOURCE_COMMIT"), sourceRevision); assert.equal(cloudApiEnv.get("HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT"), sourceRevision); assert.match(cloudApiEnv.get("HWLAB_ENVIRONMENT_IMAGE") ?? "", /hwlab-cloud-api-env:env-stale/u); + assert.deepEqual(cloudApiEnvEntries.get("HWLAB_SECRET_PLANE_SMOKE")?.valueFrom?.secretKeyRef, { name: "hwlab-secret-plane-smoke", key: "password", optional: true }); assert.ok((cloudApi?.spec?.template?.spec?.volumes ?? []).some((volume) => volume.name === "hwpod-preinstalled-specs" && volume.configMap?.name === "hwlab-v03-hwpod-preinstalled-specs")); assert.ok((cloudApiContainer?.volumeMounts ?? []).some((mount) => mount.name === "hwpod-preinstalled-specs" && mount.mountPath === "/etc/hwlab/hwpod-specs")); assert.ok((cloudApiContainer?.env ?? []).some((entry) => entry.name === "HWLAB_HWPOD_SPEC_REGISTRY_DIRS" && entry.value === "/etc/hwlab/hwpod-specs"));