diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index a41f16f9..6cfc2286 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -71,6 +71,7 @@ "hwlab-cloud-api", "hwlab-workbench-runtime", "hwlab-user-billing", + "hwlab-project-management", "hwlab-cloud-web", "hwlab-gateway", "hwlab-edge-proxy", @@ -359,6 +360,10 @@ "services": { "type": "array", "items": { "$ref": "#/$defs/laneService" } + }, + "configMaps": { + "type": "array", + "items": { "$ref": "#/$defs/laneConfigMap" } } }, "additionalProperties": false @@ -689,10 +694,65 @@ "env": { "type": "object", "additionalProperties": { "type": "string" } + }, + "configMapMounts": { + "type": "array", + "items": { "$ref": "#/$defs/configMapMount" } } }, "additionalProperties": false }, + "laneConfigMap": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { "$ref": "#/$defs/k8sName" }, + "labels": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "annotations": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "data": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false + }, + "configMapMount": { + "type": "object", + "required": ["name", "configMapName", "mountPath"], + "properties": { + "name": { "$ref": "#/$defs/k8sName" }, + "configMapName": { "$ref": "#/$defs/k8sName" }, + "mountPath": { "type": "string", "pattern": "^/" }, + "readOnly": { "type": "boolean" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/configMapMountItem" } + } + }, + "additionalProperties": false + }, + "configMapMountItem": { + "type": "object", + "required": ["key", "path"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1, "not": { "pattern": "(^/|(^|/)\\.\\.(/|$))" } } + }, + "additionalProperties": false + }, + "k8sName": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, "repoPath": { "type": "string", "minLength": 1, diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 010f652a..49a92d39 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -91,6 +91,43 @@ lanes: node: G14 sourceBranch: v0.2 gitopsBranch: v0.2-gitops + gitMirror: + namespace: devops-infra + serviceReadName: git-mirror-http + serviceWriteName: git-mirror-write + cachePvcName: hwlab-git-mirror-cache + cachePvcStorage: 20Gi + cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-d601-v02-git-mirror-cache + servicePort: 8080 + syncConfigMapName: git-mirror-sync-script + egressProxy: + mode: node-global + required: true + namespace: platform-infra + serviceName: sub2api-egress-proxy + port: 10808 + clientName: d601-global-proxy + noProxy: + - localhost + - 127.0.0.1 + - ::1 + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - kubernetes + - kubernetes.default + - kubernetes.default.svc + - 10.0.0.0/8 + - 10.42.0.0/16 + - 10.43.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 82.156.23.220 + - 74.48.78.17 + - hyueapi.com + - .hyueapi.com namespace: hwlab-v02 endpoint: https://hwlab.74-48-78-17.nip.io publicEndpoints: @@ -575,6 +612,82 @@ lanes: bootConfig: template: default overrides: {} + configMaps: + - name: hwlab-v03-hwpod-preinstalled-specs + labels: + hwlab.pikastech.local/config-kind: hwpod-preinstalled-specs + annotations: + hwlab.pikastech.local/source-ref: config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall + hwlab.pikastech.local/verification-issue: pikasTech/HWLAB#2183 + data: + constart-71freq-c.yaml: |- + apiVersion: hwlab.dev/v0alpha1 + kind: Hwpod + metadata: + uid: CONSTART-71FREQ-C + name: constart-71freq-c + spec: + targetDevice: + board: ConStart 71-FREQ Controller + mcu: STM32H723ZGTx + flashBase: "0x08000000" + workspace: + path: "F:\\Work\\ConStart" + toolchain: keil-mdk + projectRoot: projects/71-00075-11 + keilProject: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx + keilTarget: FREQ_Controller_FW + hexPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex + mapPath: projects/71-00075-11/FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map + keilCliPath: "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py" + debugProbe: + type: cmsis-dap + adapter: keil + probeUid: 3FD750C63E342E24 + probeName: MicroLink CMSIS-DAP + programBackend: keil-headless + autoBindUvoptx: true + ioProbe: + uart: + id: uart/1 + port: COM4 + baudrate: 921600 + nodeBinding: + nodeId: node-d601-f103-v2 + nodeType: pc-host + constart-71freq-c.meta.json: |- + { + "contractVersion": "hwpod-spec-registry-v1", + "source": { + "kind": "preinstalled-yaml-first-spec", + "workspaceRoot": "F:\\Work\\ConStart", + "projectRoot": "projects/71-00075-11", + "verificationIssue": "pikasTech/HWLAB#2183", + "verifiedAt": "2026-06-26" + } + } + - name: hwlab-v03-project-management-bootstrap + labels: + hwlab.pikastech.local/config-kind: project-management-bootstrap-sources + annotations: + hwlab.pikastech.local/source-ref: config/hwlab-project-management/constart-71freq-mdtodo.yaml#projectManagement.sources[0] + hwlab.pikastech.local/verification-issue: pikasTech/HWLAB#2183 + data: + sources.json: |- + [ + { + "sourceId": "constart-71freq-mdtodo", + "sourceKind": "hwpod-workspace", + "displayName": "71-FREQ MDTODO", + "projectId": "project_constart_71freq", + "hwpodId": "constart-71freq-c", + "nodeId": "node-d601-f103-v2", + "workspaceRootRef": "F:\\Work\\ConStart", + "mdtodoRootRef": "docs/MDTODO", + "maxFiles": 300, + "capabilities": ["probe", "list", "read", "write", "reindex"] + } + ] services: - serviceId: hwlab-cloud-api replicas: 1 @@ -596,6 +709,7 @@ lanes: HWLAB_USER_BILLING_URL: http://hwlab-user-billing.hwlab-v03.svc.cluster.local:6670 HWLAB_WORKBENCH_RUNTIME_URL: http://hwlab-workbench-runtime.hwlab-v03.svc.cluster.local:6671 HWLAB_PROJECT_MANAGEMENT_URL: http://hwlab-project-management.hwlab-v03.svc.cluster.local:6672 + HWLAB_HWPOD_SPEC_REGISTRY_DIRS: /etc/hwlab/hwpod-specs HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2" HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS: "250" HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" @@ -619,6 +733,11 @@ 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" + configMapMounts: + - name: hwpod-preinstalled-specs + configMapName: hwlab-v03-hwpod-preinstalled-specs + mountPath: /etc/hwlab/hwpod-specs + readOnly: true - serviceId: hwlab-workbench-runtime env: HWLAB_WORKBENCH_RUNTIME_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url @@ -653,8 +772,14 @@ lanes: HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03 HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops + HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: /etc/hwlab/project-management/sources.json OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces OTEL_SERVICE_NAME: hwlab-project-management + configMapMounts: + - name: project-management-bootstrap + configMapName: hwlab-v03-project-management-bootstrap + mountPath: /etc/hwlab/project-management + readOnly: true services: - serviceId: hwlab-cloud-api namespace: hwlab-dev diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index a157c40e..e2757988 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -188,6 +188,50 @@ test("project management app configures HWPOD source and writes MDTODO content", } }); +test("project management app bootstraps HWPOD sources from a mounted JSON file", async () => { + const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-bootstrap-")); + const defaultRoot = join(root, "default"); + const hwpodRoot = join(root, "hwpod-workspace"); + const mdtodoDir = join(hwpodRoot, "docs", "MDTODO"); + const configPath = join(root, "sources.json"); + try { + await mkdir(defaultRoot, { recursive: true }); + await mkdir(mdtodoDir, { recursive: true }); + await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8"); + await writeFile(join(mdtodoDir, "bootstrap.md"), "# Bootstrap\n\n## R1 From mounted source\n", "utf8"); + await writeFile(configPath, JSON.stringify({ + sources: [{ + sourceId: "constart-71freq-mdtodo", + sourceKind: "hwpod-workspace", + displayName: "71-FREQ MDTODO", + projectId: "project_constart_71freq", + hwpodId: "constart-71freq-c", + nodeId: "node-d601-f103-v2", + workspaceRootRef: hwpodRoot, + mdtodoRootRef: "docs/MDTODO" + }] + }), "utf8"); + const app = createProjectManagementApp({ + env: { HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: configPath }, + store: createProjectManagementStore({ kind: "memory" }), + sourceRoot: defaultRoot, + sourceId: "default-mdtodo", + projectId: "project_test", + hwpodNodeOpsHandler: (plan) => executeHwpodNodeOpsPlan(plan) + }); + + const ready = await app.fetch(new Request("http://service/health/ready")); + expect(ready.status).toBe(200); + const sources = await json(app, "/v1/project-management/mdtodo/sources"); + expect(sources.sources.map((source) => source.sourceId).sort()).toEqual(["constart-71freq-mdtodo", "default-mdtodo"]); + const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=constart-71freq-mdtodo"); + expect(files.files[0].relativePath).toBe("bootstrap.md"); + await app.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + async function json(app, path) { const response = await app.fetch(new Request(`http://service${path}`, { headers: { "x-hwlab-actor-id": "usr_test" } })); expect(response.status).toBe(200); diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index f07fea25..c1e74d78 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { mutateMdtodoDocument } from "./mdtodo.ts"; @@ -37,10 +38,23 @@ export function createProjectManagementApp(options = {}) { initializing = (async () => { const startedAt = new Date().toISOString(); await store.ensureSchema(); - await store.upsertSource(defaultSource); - const documents = await sourceAdapter.discover(defaultSource); - await store.replaceProjection(defaultSource, documents); - lastProjection = { ...sourceProjection(defaultSource, documents), startedAt }; + const bootstrapSources = await projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles }); + const sources = dedupeProjectSources([defaultSource, ...bootstrapSources]); + const projections = []; + for (const source of sources) { + await store.upsertSource(source); + try { + const documents = await sourceAdapter.discover(source); + await store.replaceProjection(source, documents); + projections.push({ ...sourceProjection(source, documents), status: "ready" }); + } catch (error) { + if (source.sourceId === defaultSource.sourceId) throw error; + const blocker = { code: error?.code ?? "project_source_projection_failed", message: error?.message ?? String(error) }; + projections.push({ ...sourceProjection(source, []), status: "blocked", blocker }); + logger.warn?.(JSON.stringify({ event: "project-management-bootstrap-source-projection-failed", serviceId, sourceId: source.sourceId, sourceKind: source.sourceKind, ...blocker })); + } + } + lastProjection = summarizeProjectManagementProjection(projections, startedAt); initialized = true; lastError = null; logger.info?.(JSON.stringify({ event: "project-management-projection-ready", serviceId, ...lastProjection })); @@ -165,6 +179,56 @@ export function createProjectManagementApp(options = {}) { }; } +async function projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles }) { + const inputs = await readProjectManagementBootstrapSourceInputs(env); + return inputs.map((input, index) => { + const normalized = normalizeProjectSourceInput(input, { projectId, displayName, rootRef: sourceRoot, maxFiles }); + if (!normalized.ok) { + const error = Object.assign(new Error(`bootstrap source[${index}] invalid: ${normalized.message}`), { + code: normalized.code, + statusCode: normalized.status + }); + throw error; + } + return normalized.source; + }); +} + +async function readProjectManagementBootstrapSourceInputs(env) { + const pathValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH ?? "").trim(); + const inlineValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_JSON ?? "").trim(); + if (!pathValue && !inlineValue) return []; + const text = pathValue ? await readFile(pathValue, "utf8") : inlineValue; + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) return parsed; + if (Array.isArray(parsed?.sources)) return parsed.sources; + const error = Object.assign(new Error("Project management bootstrap sources must be a JSON array or an object with a sources array"), { + code: "invalid_project_management_bootstrap_sources", + statusCode: 400 + }); + throw error; +} + +function dedupeProjectSources(sources) { + const byId = new Map(); + for (const source of sources) byId.set(source.sourceId, source); + return [...byId.values()]; +} + +function summarizeProjectManagementProjection(projections, startedAt) { + if (projections.length === 1) return { ...projections[0], startedAt }; + return { + sourceId: projections[0]?.sourceId ?? "project-management", + sourceCount: projections.length, + sources: projections, + documentCount: projections.reduce((sum, item) => sum + Number(item.documentCount ?? 0), 0), + taskCount: projections.reduce((sum, item) => sum + Number(item.taskCount ?? 0), 0), + projectedAt: new Date().toISOString(), + startedAt, + valuesRedacted: true + }; +} + function healthPayload(mode, state) { const blockers = state.lastError ? [{ code: state.lastError.code ?? "project_management_not_ready", message: state.lastError.message ?? String(state.lastError) }] : []; return { diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 03509f3f..797e9d64 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -932,6 +932,33 @@ function applyDeployEnv(envList, deployService, namespace, profile = "dev") { } } +function applyDeployConfigMapMounts(podSpec, container, deployService) { + const mounts = Array.isArray(deployService?.configMapMounts) ? deployService.configMapMounts : []; + if (mounts.length === 0 || !podSpec || !container) return; + podSpec.volumes ??= []; + container.volumeMounts ??= []; + for (const mount of mounts) { + const name = String(mount?.name ?? "").trim(); + const configMapName = String(mount?.configMapName ?? "").trim(); + const mountPath = String(mount?.mountPath ?? "").trim(); + if (!name || !configMapName || !mountPath) continue; + const items = Array.isArray(mount.items) + ? mount.items + .map((item) => ({ key: String(item?.key ?? "").trim(), path: String(item?.path ?? "").trim() })) + .filter((item) => item.key && item.path) + : []; + const volume = { name, configMap: { name: configMapName } }; + if (items.length > 0) volume.configMap.items = items; + const existingVolume = podSpec.volumes.find((entry) => entry?.name === name); + if (existingVolume) Object.assign(existingVolume, volume); + else podSpec.volumes.push(volume); + const volumeMount = { name, mountPath, readOnly: mount.readOnly !== false }; + const existingMount = container.volumeMounts.find((entry) => entry?.name === name); + if (existingMount) Object.assign(existingMount, volumeMount); + else container.volumeMounts.push(volumeMount); + } +} + function removeV02LegacySimulatorEnv(envList) { if (!Array.isArray(envList)) return envList; return envList.filter((entry) => !v02RemovedServiceEnvNames.has(entry?.name)); @@ -1327,6 +1354,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); } applyDeployEnv(container.env, deployService, namespace, profile); + applyDeployConfigMapMounts(podTemplate.spec, container, deployService); if (runtimeLane) container.env = removeV02LegacySimulatorEnv(container.env); upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); @@ -5368,9 +5396,37 @@ function workbenchRuntimeRedisManifest({ profile = "v03", config, source }) { }; } -function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false } = {}) { +function runtimeConfigMapsForProfile(deploy, profile) { + if (!isRuntimeLane(profile)) return []; + const configMaps = deploy?.lanes?.[profile]?.configMaps; + if (!Array.isArray(configMaps)) return []; + return configMaps + .map((configMap) => cloneJson(configMap)) + .filter((configMap) => typeof configMap?.name === "string" && configMap.name.trim() && configMap.data && typeof configMap.data === "object" && !Array.isArray(configMap.data)); +} + +function runtimeConfigMapsManifest({ configMaps, namespace, labels, annotations }) { + return { + apiVersion: "v1", + kind: "List", + items: configMaps.map((configMap) => ({ + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: configMap.name, + namespace, + labels: { ...labels, ...(configMap.labels ?? {}) }, + annotations: { ...annotations, ...(configMap.annotations ?? {}) } + }, + data: Object.fromEntries(Object.entries(configMap.data).map(([key, value]) => [key, String(value)])) + })) + }; +} + +function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = 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 (isRuntimeLane(profile)) { if (externalPostgres) resources.push("external-postgres.yaml"); else resources.push("postgres.yaml"); @@ -5510,6 +5566,7 @@ async function plannedFiles(args) { const includeDeviceAgent = profile === "dev"; const externalPostgres = externalPostgresConfigForLane(deploy, profile); const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile); + const runtimeConfigMaps = runtimeConfigMapsForProfile(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; @@ -5517,11 +5574,12 @@ 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) })); + putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0 })); 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 })); 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 36095bc2..7e693d88 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -532,6 +532,7 @@ test("v03 render keeps node identity as data instead of generated structure", as assert.ok(generatedPaths.includes("argocd/application-v03.yaml")); 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")); 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); @@ -542,6 +543,19 @@ 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 configMapNames = (configMapsJson.items ?? []).map((item) => item.metadata?.name).sort(); + assert.deepEqual(configMapNames, ["hwlab-v03-hwpod-preinstalled-specs", "hwlab-v03-project-management-bootstrap"]); + 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"); + 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")); + const projectManagement = (workloadsJson.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-project-management"); + const projectManagementContainer = collectContainersFromItem(projectManagement).find((container) => container.name === "hwlab-project-management"); + assert.ok((projectManagement?.spec?.template?.spec?.volumes ?? []).some((volume) => volume.name === "project-management-bootstrap" && volume.configMap?.name === "hwlab-v03-project-management-bootstrap")); + assert.ok((projectManagementContainer?.volumeMounts ?? []).some((mount) => mount.name === "project-management-bootstrap" && mount.mountPath === "/etc/hwlab/project-management")); + assert.ok((projectManagementContainer?.env ?? []).some((entry) => entry.name === "HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH" && entry.value === "/etc/hwlab/project-management/sources.json")); const externalPostgres = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "external-postgres.yaml"), "utf8")); assert.deepEqual((externalPostgres.items ?? []).map((item) => item.kind), ["Service", "EndpointSlice"]); const externalPostgresSlice = (externalPostgres.items ?? []).find((item) => item.kind === "EndpointSlice");