diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 13ad3a92..14913651 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -2,6 +2,28 @@ "apiVersion": "v1", "kind": "List", "items": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "labels": { + "app.kubernetes.io/name": "hwlab-code-agent-workspace", + "hwlab.pikastech.local/service-id": "hwlab-code-agent-workspace" + }, + "name": "hwlab-code-agent-workspace", + "namespace": "hwlab-dev" + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "8Gi" + } + } + } + }, { "apiVersion": "apps/v1", "kind": "Deployment", @@ -15,6 +37,9 @@ }, "spec": { "replicas": 1, + "strategy": { + "type": "Recreate" + }, "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-cloud-api" @@ -28,6 +53,30 @@ } }, "spec": { + "initContainers": [ + { + "command": [ + "sh", + "-ceu", + "target=/workspace/hwlab\nmarker=\"$target/.hwlab-workspace-initialized\"\nmkdir -p \"$target\"\nif [ ! -e \"$marker\" ]; then\n if [ -z \"$(find \"$target\" -mindepth 1 -maxdepth 1 ! -name lost+found -print -quit)\" ]; then\n cp -a /app/. \"$target\"/\n mode=copied-from-image\n else\n mode=adopted-existing-workspace\n fi\n printf '%s\\n' \"$mode\" > \"$marker\"\n printf '%s\\n' \"${HWLAB_WORKSPACE_SOURCE_COMMIT:-unknown}\" > \"$target/.hwlab-workspace-base-revision\"\nfi\nprintf '%s\\n' \"${HWLAB_WORKSPACE_SOURCE_COMMIT:-unknown}\" > \"$target/.hwlab-workspace-current-image-revision\"\n" + ], + "env": [ + { + "name": "HWLAB_WORKSPACE_SOURCE_COMMIT", + "value": "af46386" + } + ], + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:af46386", + "imagePullPolicy": "IfNotPresent", + "name": "hwlab-code-agent-workspace-init", + "volumeMounts": [ + { + "mountPath": "/workspace", + "name": "hwlab-code-agent-workspace" + } + ] + } + ], "containers": [ { "name": "hwlab-cloud-api", @@ -303,7 +352,9 @@ "volumes": [ { "name": "hwlab-code-agent-workspace", - "emptyDir": {} + "persistentVolumeClaim": { + "claimName": "hwlab-code-agent-workspace" + } }, { "name": "hwlab-code-agent-codex-home", diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs index 15d8ff99..6a6a5f08 100644 --- a/internal/cloud/code-agent-contract.mjs +++ b/internal/cloud/code-agent-contract.mjs @@ -10,6 +10,8 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ model: "gpt-5.5", requiredEnv: Object.freeze([ "OPENAI_API_KEY", + "HWLAB_CODE_AGENT_WORKSPACE", + "HWLAB_CODE_AGENT_CODEX_WORKSPACE", "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "HWLAB_CODE_AGENT_OPENAI_BASE_URL", "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", @@ -63,6 +65,19 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ authKey: "auth.json", mountContract: "writable emptyDir CODEX_HOME with read-only config.toml/auth.json file mounts" }), + workspace: Object.freeze({ + path: "/workspace/hwlab", + mountPath: "/workspace", + sourcePath: "/app", + volumeName: "hwlab-code-agent-workspace", + claimName: "hwlab-code-agent-workspace", + accessMode: "ReadWriteOnce", + storage: "8Gi", + initContainerName: "hwlab-code-agent-workspace-init", + initMarker: ".hwlab-workspace-initialized", + rolloutStrategy: "Recreate", + mountContract: "persistent RWO PVC mounted at /workspace with copy-once /app bootstrap into /workspace/hwlab" + }), noProxyRequired: Object.freeze([ "hyueapi.com", ".hyueapi.com", @@ -102,6 +117,8 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE [contract.forwarder.upstreamEnv]: contract.forwarder.upstreamBaseUrl, [contract.forwarder.portEnv]: String(contract.forwarder.port), [contract.egress.env]: contract.egress.defaultBaseUrl, + HWLAB_CODE_AGENT_WORKSPACE: contract.workspace.path, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: contract.workspace.path, CODEX_HOME: contract.codexHome.path, NO_PROXY: contract.noProxyRequired.join(","), no_proxy: contract.noProxyRequired.join(","), @@ -146,6 +163,26 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE if (workloadInspection.codexHome.authSecretPresent !== true) { missingCodexHomeContract.push(`${contract.codexHome.secretName}/${contract.codexHome.authKey}`); } + const missingWorkspaceContract = []; + if ( + deployEnv?.HWLAB_CODE_AGENT_WORKSPACE !== contract.workspace.path || + deployEnv?.HWLAB_CODE_AGENT_CODEX_WORKSPACE !== contract.workspace.path || + workloadInspection.workspace.present !== true + ) { + missingWorkspaceContract.push(`Code Agent workspace must be ${contract.workspace.path}`); + } + if (workloadInspection.workspace.writableMountPresent !== true) { + missingWorkspaceContract.push(`${contract.workspace.mountPath} must be a writable volumeMount`); + } + if (workloadInspection.workspace.persistentVolumeClaimPresent !== true) { + missingWorkspaceContract.push(`${contract.workspace.volumeName} must use PVC ${contract.workspace.claimName}`); + } + if (workloadInspection.workspace.initContainerPresent !== true) { + missingWorkspaceContract.push(`${contract.workspace.initContainerName} must bootstrap ${contract.workspace.path} from ${contract.workspace.sourcePath}`); + } + if (workloadInspection.workspace.recreateStrategy !== true) { + missingWorkspaceContract.push(`hwlab-cloud-api rollout strategy must be ${contract.workspace.rolloutStrategy}`); + } const missingNoProxyContract = [...new Set([ ...noProxyMissing(deployEnv?.NO_PROXY, contract.noProxyRequired), ...noProxyMissing(deployEnv?.no_proxy, contract.noProxyRequired), @@ -160,6 +197,7 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE missingSecretRefs.length === 0 && missingEgressContract.length === 0 && missingCodexHomeContract.length === 0 && + missingWorkspaceContract.length === 0 && missingNoProxyContract.length === 0 && missingForwarderContract.length === 0; @@ -213,9 +251,11 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE missingSecretRefs, missingEgressContract, missingCodexHomeContract, + missingWorkspaceContract, missingNoProxyContract, missingForwarderContract, codexHome: workloadInspection.codexHome, + workspace: workloadInspection.workspace, forwarder: workloadInspection.forwarder, noProxy: workloadInspection.noProxy, secretMaterialRead: false, @@ -240,6 +280,8 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { [contract.forwarder.upstreamEnv]: contract.forwarder.upstreamBaseUrl, [contract.forwarder.portEnv]: String(contract.forwarder.port), [contract.egress.env]: contract.egress.defaultBaseUrl, + HWLAB_CODE_AGENT_WORKSPACE: contract.workspace.path, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: contract.workspace.path, CODEX_HOME: contract.codexHome.path, NO_PROXY: contract.noProxyRequired.join(","), no_proxy: contract.noProxyRequired.join(",") @@ -276,11 +318,15 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { const matchesDevProxy = baseUrl === contract.egress.defaultBaseUrl; const upstreamMatchesDirectHyueapi = upstreamBaseUrl === contract.egress.upstreamBaseUrl; const codexHomePresent = envEntryValue(getEnvEntry(workloadEnv, "CODEX_HOME")) === contract.codexHome.path; + const workspacePathPresent = + envEntryValue(getEnvEntry(workloadEnv, "HWLAB_CODE_AGENT_WORKSPACE")) === contract.workspace.path && + envEntryValue(getEnvEntry(workloadEnv, "HWLAB_CODE_AGENT_CODEX_WORKSPACE")) === contract.workspace.path; const noProxyMissingEntries = [...new Set([ ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "NO_PROXY")), contract.noProxyRequired), ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "no_proxy")), contract.noProxyRequired) ])]; const codexHome = inspectCodexHomeWorkloadMounts(workloadEnv); + const workspace = inspectCodeAgentWorkspaceWorkloadMounts(workloadEnv); const forwarder = inspectCodexApiForwarderWorkload(workloadEnv); const ready = missingEnv.length === 0 && @@ -292,6 +338,8 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { codexHome.present === true && codexHome.configMapPresent === true && codexHome.authSecretPresent === true && + workspacePathPresent && + workspace.present === true && noProxyMissingEntries.length === 0 && forwarder.present === true; @@ -327,6 +375,10 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { ...codexHome, present: codexHomePresent && codexHome.present === true }, + workspace: { + ...workspace, + present: workspacePathPresent && workspace.present === true + }, forwarder, noProxy: { required: [...contract.noProxyRequired], @@ -357,6 +409,7 @@ export function buildCodeAgentProviderManifestPlaceholder() { command: [...contract.forwarder.command] }, codexHome: { ...contract.codexHome }, + workspace: { ...contract.workspace }, noProxyRequired: [...contract.noProxyRequired], fixtureEvidence: { providerConnected: false, @@ -384,7 +437,9 @@ export function inspectCodeAgentProviderEnv(env = {}) { const noProxyReady = noProxyMissing(env.NO_PROXY, contract.noProxyRequired).length === 0 && noProxyMissing(env.no_proxy, contract.noProxyRequired).length === 0; const codexHomeReady = firstNonEmpty(env.CODEX_HOME) === contract.codexHome.path; - const ready = missingEnv.length === 0 && egressReady && codexHomeReady && noProxyReady; + const workspaceReady = firstNonEmpty(env.HWLAB_CODE_AGENT_WORKSPACE) === contract.workspace.path && + firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE) === contract.workspace.path; + const ready = missingEnv.length === 0 && egressReady && codexHomeReady && workspaceReady && noProxyReady; return { contractVersion: contract.contractVersion, @@ -443,6 +498,15 @@ export function inspectCodeAgentProviderEnv(env = {}) { secretMaterialRead: false, valuesRedacted: true }, + workspace: { + path: contract.workspace.path, + mountPath: contract.workspace.mountPath, + claimName: contract.workspace.claimName, + initContainerName: contract.workspace.initContainerName, + rolloutStrategy: contract.workspace.rolloutStrategy, + present: workspaceReady, + persistentVolumeClaimPresent: "not_observed_by_runtime" + }, noProxy: { required: [...contract.noProxyRequired], missing: [...new Set([ @@ -562,6 +626,56 @@ function inspectCodexHomeWorkloadMounts(workloadEnv = {}) { }; } +function inspectCodeAgentWorkspaceWorkloadMounts(workloadEnv = {}) { + const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; + const volumeMounts = Array.isArray(workloadEnv.__volumeMounts) ? workloadEnv.__volumeMounts : []; + const volumes = Array.isArray(workloadEnv.__volumes) ? workloadEnv.__volumes : []; + const initContainers = Array.isArray(workloadEnv.__initContainers) ? workloadEnv.__initContainers : []; + const deploymentStrategy = workloadEnv.__deploymentStrategy; + const workspaceMount = volumeMounts.find((entry) => + entry?.name === contract.workspace.volumeName && + entry?.mountPath === contract.workspace.mountPath + ); + const writableMountPresent = Boolean(workspaceMount && workspaceMount.readOnly !== true); + const workspaceVolume = volumes.find((entry) => + entry?.name === contract.workspace.volumeName && + entry?.persistentVolumeClaim?.claimName === contract.workspace.claimName + ); + const initContainer = initContainers.find((entry) => entry?.name === contract.workspace.initContainerName); + const initContainerMountPresent = (initContainer?.volumeMounts ?? []).some((entry) => + entry?.name === contract.workspace.volumeName && + entry?.mountPath === contract.workspace.mountPath + ); + const initCommand = [...(initContainer?.command ?? []), ...(initContainer?.args ?? [])].join("\n"); + const copyOnceCommandPresent = initCommand.includes(contract.workspace.sourcePath) && + initCommand.includes(contract.workspace.path) && + initCommand.includes(contract.workspace.initMarker); + const recreateStrategy = deploymentStrategy?.type === contract.workspace.rolloutStrategy; + const persistentVolumeClaimPresent = Boolean(workspaceVolume); + const initContainerPresent = Boolean(initContainer && initContainerMountPresent && copyOnceCommandPresent); + return { + path: contract.workspace.path, + mountPath: contract.workspace.mountPath, + volumeName: contract.workspace.volumeName, + claimName: contract.workspace.claimName, + accessMode: contract.workspace.accessMode, + storage: contract.workspace.storage, + initContainerName: contract.workspace.initContainerName, + initMarker: contract.workspace.initMarker, + rolloutStrategy: contract.workspace.rolloutStrategy, + present: Boolean(writableMountPresent && persistentVolumeClaimPresent && initContainerPresent && recreateStrategy), + writableMountPresent, + persistentVolumeClaimPresent, + initContainerPresent, + initContainerMountPresent, + copyOnceCommandPresent, + recreateStrategy, + mountContract: contract.workspace.mountContract, + secretMaterialRead: false, + valuesRedacted: true + }; +} + function noProxyMissing(value, required) { const entries = new Set(String(value ?? "") .split(/[,;\s]+/u) diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index d3ae0ee9..5e100ab4 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -42,6 +42,11 @@ async function makeFixture({ workloadCodeAgentProviderEnv = true, workloadCodexHomeVolumeMounts = true, workloadCodexHomeVolumes = true, + workloadWorkspaceVolumeMount = true, + workloadWorkspaceVolume = true, + workloadWorkspaceInitContainer = true, + workloadWorkspaceRecreateStrategy = true, + workloadWorkspacePvc = true, workloadDbEnv = true, workloadDbSslMode = "disable" } = {}) { @@ -65,6 +70,8 @@ async function makeFixture({ HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL: "https://hyueapi.com", HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT: "49280", HWLAB_CODE_AGENT_OPENAI_BASE_URL: providerBaseUrl, + HWLAB_CODE_AGENT_WORKSPACE: "/workspace/hwlab", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: "/workspace/hwlab", CODEX_HOME: "/codex-home", NO_PROXY: providerNoProxy, no_proxy: providerNoProxy, @@ -84,6 +91,8 @@ async function makeFixture({ { name: "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", value: "https://hyueapi.com" }, { name: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", value: "49280" }, { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: providerBaseUrl }, + { name: "HWLAB_CODE_AGENT_WORKSPACE", value: "/workspace/hwlab" }, + { name: "HWLAB_CODE_AGENT_CODEX_WORKSPACE", value: "/workspace/hwlab" }, { name: "CODEX_HOME", value: "/codex-home" }, { name: "NO_PROXY", value: providerNoProxy }, { name: "no_proxy", value: providerNoProxy }, @@ -175,6 +184,29 @@ async function makeFixture({ apiVersion: "v1", kind: "List", items: [ + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadWorkspacePvc + ? [ + { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { + name: "hwlab-code-agent-workspace", + namespace: "hwlab-dev", + labels: { + "hwlab.pikastech.local/service-id": "hwlab-code-agent-workspace" + } + }, + spec: { + accessModes: ["ReadWriteOnce"], + resources: { + requests: { + storage: "8Gi" + } + } + } + } + ] + : []), { apiVersion: "apps/v1", kind: "Deployment", @@ -186,8 +218,33 @@ async function makeFixture({ } }, spec: { + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadWorkspaceRecreateStrategy + ? { + strategy: { + type: "Recreate" + } + } + : {}), template: { spec: { + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadWorkspaceInitContainer + ? { + initContainers: [ + { + name: "hwlab-code-agent-workspace-init", + image: workloadImage, + command: [ + "sh", + "-ceu", + "target=/workspace/hwlab\nmarker=\"$target/.hwlab-workspace-initialized\"\nmkdir -p \"$target\"\nif [ ! -e \"$marker\" ]; then\n cp -a /app/. \"$target\"/\n printf '%s\\n' copied-from-image > \"$marker\"\nfi\n" + ], + volumeMounts: [ + { name: "hwlab-code-agent-workspace", mountPath: "/workspace" } + ] + } + ] + } + : {}), containers: [ { name: serviceId, @@ -202,12 +259,19 @@ async function makeFixture({ ...workloadProviderEnv ] : [], - ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadCodexHomeVolumeMounts + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && (workloadWorkspaceVolumeMount || workloadCodexHomeVolumeMounts) ? { volumeMounts: [ - { name: "hwlab-code-agent-codex-home", mountPath: "/codex-home" }, - { name: "hwlab-code-agent-codex-config", mountPath: "/codex-home/config.toml", subPath: "config.toml", readOnly: true }, - { name: "hwlab-code-agent-codex-auth", mountPath: "/codex-home/auth.json", subPath: "auth.json", readOnly: true } + ...(workloadWorkspaceVolumeMount + ? [{ name: "hwlab-code-agent-workspace", mountPath: "/workspace" }] + : []), + ...(workloadCodexHomeVolumeMounts + ? [ + { name: "hwlab-code-agent-codex-home", mountPath: "/codex-home" }, + { name: "hwlab-code-agent-codex-config", mountPath: "/codex-home/config.toml", subPath: "config.toml", readOnly: true }, + { name: "hwlab-code-agent-codex-auth", mountPath: "/codex-home/auth.json", subPath: "auth.json", readOnly: true } + ] + : []) ] } : {}) @@ -230,12 +294,19 @@ async function makeFixture({ ] : []) ], - ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadCodexHomeVolumes + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && (workloadWorkspaceVolume || workloadCodexHomeVolumes) ? { volumes: [ - { name: "hwlab-code-agent-codex-home", emptyDir: {} }, - { name: "hwlab-code-agent-codex-config", configMap: { name: "hwlab-code-agent-codex-config", items: [{ key: "config.toml", path: "config.toml" }] } }, - { name: "hwlab-code-agent-codex-auth", secret: { secretName: "hwlab-code-agent-codex-auth", items: [{ key: "auth.json", path: "auth.json" }] } } + ...(workloadWorkspaceVolume + ? [{ name: "hwlab-code-agent-workspace", persistentVolumeClaim: { claimName: "hwlab-code-agent-workspace" } }] + : []), + ...(workloadCodexHomeVolumes + ? [ + { name: "hwlab-code-agent-codex-home", emptyDir: {} }, + { name: "hwlab-code-agent-codex-config", configMap: { name: "hwlab-code-agent-codex-config", items: [{ key: "config.toml", path: "config.toml" }] } }, + { name: "hwlab-code-agent-codex-auth", secret: { secretName: "hwlab-code-agent-codex-auth", items: [{ key: "auth.json", path: "auth.json" }] } } + ] + : []) ] } : {}) @@ -307,6 +378,11 @@ test("passes when cloud-api preserves provider env and DEV egress contract", asy assert.equal(plan.codeAgentProvider.codexHome.emptyDirPresent, true); assert.equal(plan.codeAgentProvider.codexHome.configMapPresent, true); assert.equal(plan.codeAgentProvider.codexHome.authSecretPresent, true); + assert.equal(plan.codeAgentProvider.workspace.present, true); + assert.equal(plan.codeAgentProvider.workspace.writableMountPresent, true); + assert.equal(plan.codeAgentProvider.workspace.persistentVolumeClaimPresent, true); + assert.equal(plan.codeAgentProvider.workspace.initContainerPresent, true); + assert.equal(plan.codeAgentProvider.workspace.recreateStrategy, true); assert.deepEqual(plan.diagnostics, []); }); @@ -324,11 +400,39 @@ test("blocks when cloud-api CODEX_HOME mount is missing its writable emptyDir vo assert.ok(diagnostic.actual.missingCodexHomeContract.includes("/codex-home must be backed by emptyDir")); }); +test("blocks when cloud-api Code Agent workspace is not backed by PVC", async () => { + const repoRoot = await makeFixture({ workloadWorkspaceVolume: false }); + const plan = await buildDesiredStatePlan({ repoRoot }); + const diagnostic = plan.diagnostics.find((entry) => entry.code === "code_agent_provider_contract_mismatch"); + + assert.equal(plan.status, "blocked"); + assert.equal(plan.codeAgentProvider.ready, false); + assert.equal(plan.codeAgentProvider.workspace.present, false); + assert.equal(plan.codeAgentProvider.workspace.writableMountPresent, true); + assert.equal(plan.codeAgentProvider.workspace.persistentVolumeClaimPresent, false); + assert.ok(diagnostic); + assert.ok(diagnostic.actual.missingWorkspaceContract.includes("hwlab-code-agent-workspace must use PVC hwlab-code-agent-workspace")); +}); + +test("blocks when cloud-api Code Agent workspace rollout strategy is not Recreate", async () => { + const repoRoot = await makeFixture({ workloadWorkspaceRecreateStrategy: false }); + const plan = await buildDesiredStatePlan({ repoRoot }); + const diagnostic = plan.diagnostics.find((entry) => entry.code === "code_agent_provider_contract_mismatch"); + + assert.equal(plan.status, "blocked"); + assert.equal(plan.codeAgentProvider.ready, false); + assert.equal(plan.codeAgentProvider.workspace.present, false); + assert.equal(plan.codeAgentProvider.workspace.recreateStrategy, false); + assert.ok(diagnostic); + assert.ok(diagnostic.actual.missingWorkspaceContract.includes("hwlab-cloud-api rollout strategy must be Recreate")); +}); + test("blocks when cloud-api CODEX_HOME mount is read-only", async () => { const repoRoot = await makeFixture(); const workloadsPath = path.join(repoRoot, "deploy/k8s/base/workloads.yaml"); const workloads = JSON.parse(await readFile(workloadsPath, "utf8")); - workloads.items[0].spec.template.spec.containers[0].volumeMounts.find((entry) => + const deployment = workloads.items.find((entry) => entry.kind === "Deployment"); + deployment.spec.template.spec.containers[0].volumeMounts.find((entry) => entry.name === "hwlab-code-agent-codex-home" ).readOnly = true; await writeFile(workloadsPath, `${JSON.stringify(workloads, null, 2)}\n`); @@ -391,7 +495,8 @@ test("blocks when cloud-api workload points Code Agent directly at public OpenAI const repoRoot = await makeFixture(); const workloadsPath = path.join(repoRoot, "deploy/k8s/base/workloads.yaml"); const workloads = JSON.parse(await readFile(workloadsPath, "utf8")); - const env = workloads.items[0].spec.template.spec.containers[0].env; + const deployment = workloads.items.find((entry) => entry.kind === "Deployment"); + const env = deployment.spec.template.spec.containers[0].env; env.find((entry) => entry.name === "HWLAB_CODE_AGENT_OPENAI_BASE_URL").value = "https://api.openai.com/v1/responses"; await writeFile(workloadsPath, `${JSON.stringify(workloads, null, 2)}\n`); diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 93d61713..ccb8768e 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -293,6 +293,19 @@ function upsertEnvEntry(envList, entry) { } } +function syncCodeAgentWorkspaceInitContainer(initContainers, { image, runtimeCommit, runtimeImageTag }) { + const initContainer = Array.isArray(initContainers) + ? initContainers.find((entry) => entry?.name === "hwlab-code-agent-workspace-init") + : null; + if (!initContainer) return; + initContainer.image = image; + initContainer.imagePullPolicy = "IfNotPresent"; + initContainer.env ??= []; + upsertEnv(initContainer.env, "HWLAB_WORKSPACE_SOURCE_COMMIT", runtimeCommit); + upsertEnv(initContainer.env, "HWLAB_IMAGE", image); + upsertEnv(initContainer.env, "HWLAB_IMAGE_TAG", runtimeImageTag); +} + function annotate(metadata, annotations) { metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; } @@ -502,6 +515,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint); } if (serviceId === "hwlab-cloud-api") { + syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag }); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "deepseek"); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index b0d01e14..ae1587e6 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -577,10 +577,15 @@ function validateCloudApiDbOptionalAliasArtifacts(ctx, services, workloads) { function validateCloudApiCodeAgentArtifacts(ctx, workloads) { const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; const secretRef = contract.secretRefs[0]; - const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0]; + const workloadItems = mapByServiceId(listItems(workloads)); + const workload = workloadItems.get("hwlab-cloud-api"); + const templateSpec = workload?.spec?.template?.spec ?? {}; + const container = templateSpec.containers?.[0]; const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry])); - const podContainers = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers ?? []; - const volumes = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.volumes ?? []; + const podContainers = templateSpec.containers ?? []; + const initContainers = templateSpec.initContainers ?? []; + const volumes = templateSpec.volumes ?? []; + const workspacePvc = workloadItems.get(contract.workspace.claimName); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.codeAgentProvider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE")?.value, contract.profiles.defaultProfile, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "cloud API workload default provider profile"); @@ -616,6 +621,7 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { expectNoProxyIncludes(ctx, env.get("no_proxy")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.no_proxy"); validateCodexApiForwarderArtifacts(ctx, podContainers, contract); validateCodexHomeMountArtifacts(ctx, container?.volumeMounts ?? [], volumes, contract); + validateCodeAgentWorkspaceArtifacts(ctx, container?.volumeMounts ?? [], volumes, initContainers, workload?.spec?.strategy ?? null, workspacePvc, contract); expect( ctx, env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl, @@ -714,6 +720,75 @@ function validateCodexHomeMountArtifacts(ctx, volumeMounts, volumes, contract) { ); } +function validateCodeAgentWorkspaceArtifacts(ctx, volumeMounts, volumes, initContainers, strategy, pvc, contract) { + const workspace = contract.workspace; + expect( + ctx, + volumeMounts.some((entry) => entry.name === workspace.volumeName && entry.mountPath === workspace.mountPath && entry.readOnly !== true), + "code_agent_workspace_mount_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumeMounts.hwlab-code-agent-workspace", + "cloud API workload Code Agent workspace writable mount" + ); + expect( + ctx, + volumes.some((entry) => entry.name === workspace.volumeName && entry.persistentVolumeClaim?.claimName === workspace.claimName), + "code_agent_workspace_pvc_volume_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.volumes.hwlab-code-agent-workspace", + "cloud API workload Code Agent workspace PVC volume" + ); + expect( + ctx, + pvc?.kind === "PersistentVolumeClaim" && pvc?.metadata?.name === workspace.claimName, + "code_agent_workspace_pvc_missing", + "deploy/k8s/base/workloads.yaml.PersistentVolumeClaim.hwlab-code-agent-workspace", + "Code Agent workspace PVC resource" + ); + expect( + ctx, + (pvc?.spec?.accessModes ?? []).includes(workspace.accessMode), + "code_agent_workspace_pvc_access_mode_mismatch", + "deploy/k8s/base/workloads.yaml.PersistentVolumeClaim.hwlab-code-agent-workspace.spec.accessModes", + "Code Agent workspace PVC uses ReadWriteOnce" + ); + expectEqual( + ctx, + pvc?.spec?.resources?.requests?.storage, + workspace.storage, + "deploy/k8s/base/workloads.yaml.PersistentVolumeClaim.hwlab-code-agent-workspace.spec.resources.requests.storage", + "Code Agent workspace PVC requested storage" + ); + const initContainer = initContainers.find((entry) => entry.name === workspace.initContainerName); + expect( + ctx, + Boolean(initContainer), + "code_agent_workspace_init_container_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.initContainers.hwlab-code-agent-workspace-init", + "cloud API workload initializes persistent Code Agent workspace" + ); + expect( + ctx, + (initContainer?.volumeMounts ?? []).some((entry) => entry.name === workspace.volumeName && entry.mountPath === workspace.mountPath), + "code_agent_workspace_init_mount_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.initContainers.hwlab-code-agent-workspace-init.volumeMounts", + "Code Agent workspace init container mounts the persistent workspace" + ); + const initCommand = [...(initContainer?.command ?? []), ...(initContainer?.args ?? [])].join("\n"); + expect( + ctx, + initCommand.includes(workspace.sourcePath) && initCommand.includes(workspace.path) && initCommand.includes(workspace.initMarker), + "code_agent_workspace_init_command_mismatch", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.initContainers.hwlab-code-agent-workspace-init.command", + "Code Agent workspace init container uses copy-once /app bootstrap" + ); + expectEqual( + ctx, + strategy?.type, + workspace.rolloutStrategy, + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.strategy.type", + "cloud API uses Recreate rollout for single-writer workspace PVC" + ); +} + function validateCodeAgentProxyTimeoutArtifacts(ctx, workloads) { const workloadItems = mapByServiceId(listItems(workloads)); const cloudWebEnv = new Map((workloadItems.get("hwlab-cloud-web")?.spec?.template?.spec?.containers?.[0]?.env ?? []).map((entry) => [entry.name, entry.value])); diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index c86aaef0..6d7cc51a 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -305,6 +305,7 @@ function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecord missingSecretRefs: inspection.missingSecretRefs, missingEgressContract: inspection.missingEgressContract, missingCodexHomeContract: inspection.missingCodexHomeContract, + missingWorkspaceContract: inspection.missingWorkspaceContract, missingNoProxyContract: inspection.missingNoProxyContract, missingForwarderContract: inspection.missingForwarderContract }, @@ -444,7 +445,9 @@ function indexWorkloads(workloads) { ...envListToObject(container?.env), __volumeMounts: Array.isArray(container?.volumeMounts) ? container.volumeMounts : [], __volumes: Array.isArray(item?.spec?.template?.spec?.volumes) ? item.spec.template.spec.volumes : [], - __podContainers: Array.isArray(item?.spec?.template?.spec?.containers) ? item.spec.template.spec.containers : [] + __podContainers: Array.isArray(item?.spec?.template?.spec?.containers) ? item.spec.template.spec.containers : [], + __initContainers: Array.isArray(item?.spec?.template?.spec?.initContainers) ? item.spec.template.spec.initContainers : [], + __deploymentStrategy: item?.spec?.strategy ?? null }, path: `${workloadsPath}.${item?.kind ?? "Workload"}.${item?.metadata?.name ?? serviceId}.containers[${containerIndex}]` }; diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 4a86d85e..36890e19 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -179,6 +179,9 @@ const cloudApiWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-cloud-api"); cloudApiWorkloadEnv.__volumeMounts = cloudApiContainer?.volumeMounts ?? []; cloudApiWorkloadEnv.__volumes = cloudApiWorkload?.spec?.template?.spec?.volumes ?? []; cloudApiWorkloadEnv.__podContainers = cloudApiWorkload?.spec?.template?.spec?.containers ?? []; +cloudApiWorkloadEnv.__initContainers = cloudApiWorkload?.spec?.template?.spec?.initContainers ?? []; +cloudApiWorkloadEnv.__deploymentStrategy = cloudApiWorkload?.spec?.strategy ?? null; +cloudApiWorkloadEnv.__workspacePvc = getWorkload(k8sWorkloads, DEV_CODE_AGENT_PROVIDER_CONTRACT.workspace.claimName); const patchPanelWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-patch-panel"); assert.equal(deployManifest.health.path, "/health/live", "deploy health source path"); assert.equal(deployManifest.publicEndpoints.frontend.url, "http://74.48.78.17:16666", "deploy frontend endpoint"); @@ -324,6 +327,7 @@ function assertCodeAgentProviderWorkloadContract(env) { assertNoProxyIncludes(env.NO_PROXY?.value, contract.noProxyRequired, "cloud-api workload NO_PROXY contract"); assertNoProxyIncludes(env.no_proxy?.value, contract.noProxyRequired, "cloud-api workload no_proxy contract"); assertCodexHomeMountContract(env, contract); + assertCodeAgentWorkspaceContract(env, contract); assertCodexApiForwarderContract(env, contract); assert.notEqual( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, @@ -403,6 +407,38 @@ function assertCodexHomeMountContract(env, contract) { ); } +function assertCodeAgentWorkspaceContract(env, contract) { + const workspace = contract.workspace; + const volumeMounts = env.__volumeMounts ?? []; + const volumes = env.__volumes ?? []; + const initContainers = env.__initContainers ?? []; + const pvc = env.__workspacePvc; + assert.ok( + volumeMounts.some((entry) => entry.name === workspace.volumeName && entry.mountPath === workspace.mountPath && entry.readOnly !== true), + "cloud-api workload Code Agent workspace writable mount" + ); + assert.ok( + volumes.some((entry) => entry.name === workspace.volumeName && entry.persistentVolumeClaim?.claimName === workspace.claimName), + "cloud-api workload Code Agent workspace PVC volume" + ); + assert.equal(pvc?.kind, "PersistentVolumeClaim", "Code Agent workspace PVC resource"); + assert.equal(pvc?.metadata?.name, workspace.claimName, "Code Agent workspace PVC name"); + assert.ok((pvc?.spec?.accessModes ?? []).includes(workspace.accessMode), "Code Agent workspace PVC uses ReadWriteOnce"); + assert.equal(pvc?.spec?.resources?.requests?.storage, workspace.storage, "Code Agent workspace PVC requested storage"); + const initContainer = initContainers.find((entry) => entry.name === workspace.initContainerName); + assert.ok(initContainer, "cloud-api workload initializes persistent Code Agent workspace"); + assert.ok( + (initContainer?.volumeMounts ?? []).some((entry) => entry.name === workspace.volumeName && entry.mountPath === workspace.mountPath), + "Code Agent workspace init container mounts the persistent workspace" + ); + const initCommand = [...(initContainer?.command ?? []), ...(initContainer?.args ?? [])].join("\n"); + assert.ok( + initCommand.includes(workspace.sourcePath) && initCommand.includes(workspace.path) && initCommand.includes(workspace.initMarker), + "Code Agent workspace init container uses copy-once /app bootstrap" + ); + assert.equal(env.__deploymentStrategy?.type, workspace.rolloutStrategy, "cloud-api uses Recreate rollout for single-writer workspace PVC"); +} + function assertM3PatchPanelDeployContract(manifestService, workloadEnv) { const route = { fromResourceId: "res_boxsimu_1", diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 40ec9e3b..997f7ee7 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -7,6 +7,13 @@ description: Build and validate the HWLAB agent-mgr and agent-worker runtime ske Skill(cli-spec) +Scope: this skill is only for HWLAB-internal Code Agent runtime work inside +the HWLAB repository and runtime, such as `/workspace/hwlab` agent sessions, +`/root/hwlab` source changes, and HWLAB-managed worker or skill services. It +does not apply to external developer workspaces such as `/root/unidesk`; those +workspaces must follow their own repository instructions and must not treat +this skill as a general UniDesk or third-party developer runtime contract. + Use this skill when working on HWLAB agent runtime skeletons. - Keep the control plane long-lived and worker execution session-scoped.