From 39eb19b89f6331ec89a56c59ad38c34c91759d80 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 4 Jun 2026 20:42:00 +0300 Subject: [PATCH 1/8] infra(foundry): hardening starter for @michaelliav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - foundry.bicep: add Anthropic deployments, federated credential for model-gateway MI, RBAC scoped to project (Cognitive Services User), Anthropic terms acceptance via deploymentScript - identity.bicep: add gatewayId user-assigned MI (separate from router/agent) - main.bicep: wire AKS OIDC issuer URL + gateway MI into foundry module Three open questions inline as TODOs (issue #2): 1. Federated identity scope (Hub vs Project vs per-deployment) 2. Content filter levels per Anthropic deployment 3. Anthropic terms acceptance API path Compiles cleanly: az bicep build → 81 KB ARM, 0 errors. --- infra/main.bicep | 3 + infra/modules/foundry.bicep | 214 ++++++++++++++++++++++++++++++++++- infra/modules/identity.bicep | 12 ++ 3 files changed, 226 insertions(+), 3 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index b42c70e..060baef 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -157,6 +157,9 @@ module foundry 'modules/foundry.bicep' = { keyVaultId: keyvault.outputs.kvId storageId: storage.outputs.storageId logAnalyticsWorkspaceId: logging.outputs.workspaceId + aksOidcIssuerUrl: aks.outputs.oidcIssuerUrl + gatewayIdentityId: identity.outputs.gatewayIdentityId + gatewayIdentityPrincipalId: identity.outputs.gatewayIdentityPrincipalId } } diff --git a/infra/modules/foundry.bicep b/infra/modules/foundry.bicep index f0155f9..c9507d5 100644 --- a/infra/modules/foundry.bicep +++ b/infra/modules/foundry.bicep @@ -1,6 +1,45 @@ // Microsoft Foundry (AI Foundry) hub + project for Claude/OpenAI deployments -// Note: Foundry deployments for Claude must be created post-deploy via portal/CLI -// because Anthropic models require terms-of-use acceptance per subscription. +// +// ────────────────────────────────────────────────────────────────────────────── +// HARDENING STARTER (Hermes → @michaelliav, see issue #2) +// ────────────────────────────────────────────────────────────────────────────── +// This file expands `infra/modules/foundry.bicep` from "Hub + Project only" to +// a complete Foundry stack: hub, project, model deployments, federated identity +// for the model-gateway, RBAC scoped to least privilege, and a deployment +// script that automates the Anthropic terms acceptance. +// +// THREE OPEN QUESTIONS (Michael's domain — please confirm/correct in PR): +// +// 1. Federated identity scope — where does the model-gateway's federated +// credential need RBAC? Options below; I'm currently betting on (b) for +// inference-token issuance but unsure if (a) is also required. +// (a) `Cognitive Services User` on the parent Hub +// (b) `Cognitive Services User` on each model deployment (resource-scoped) +// (c) `Azure AI Developer` on the project (broader; needed for hub APIs?) +// → see `roleAssignmentGateway` block below. +// +// 2. Content filter level — Foundry exposes content filters per deployment. +// The SecurityReviewer agent intentionally writes spicy code-injection +// review prompts, which can false-positive on `medium`/`high` filters and +// block legitimate workflow runs. Currently set to `low` for SecReviewer's +// Sonnet deployment, `medium` for everyone else. Defensible? Or do we need +// a custom blocklist policy attached? +// → see `contentFilterAssignments` array below. +// +// 3. Anthropic terms acceptance — Anthropic models on Foundry require a +// one-time legal acceptance per subscription. ARM doesn't expose this as a +// property; it has to be done via the Foundry portal OR by calling the +// `cognitiveservices terms accept` API path. Below is a `deploymentScripts` +// resource that calls the API automatically. Two concerns: +// - is the API path stable / GA, or do we keep this as a manual one-time +// pre-req in `docs/OPERATIONS.md`? +// - should the script run on every deploy (idempotent? probably yes, +// accept-terms is a no-op if already accepted) or only on first run? +// → see `acceptAnthropicTerms` deploymentScript below. +// +// Reply on PR or on issue #2. — Hermes +// ────────────────────────────────────────────────────────────────────────────── + param prefix string param env string param location string @@ -10,6 +49,46 @@ param keyVaultId string param storageId string param logAnalyticsWorkspaceId string +// New parameters (Michael — please review naming/defaults): + +@description('OIDC issuer URL of the AKS cluster — needed to federate the model-gateway MI to its KSA') +param aksOidcIssuerUrl string + +@description('Resource ID of the user-assigned managed identity for the model-gateway') +param gatewayIdentityId string + +@description('Principal ID of the gateway MI — used for RBAC role assignments') +param gatewayIdentityPrincipalId string + +@description('Kubernetes namespace.serviceaccount that the federated credential trusts. Format: ns:platform/sa:model-gateway') +param gatewayKubernetesSubject string = 'system:serviceaccount:platform:model-gateway' + +@description('Anthropic models to deploy on Foundry. Pin versions explicitly — aliases break when Anthropic releases new versions.') +param anthropicModels array = [ + { + name: 'claude-opus-4-8' + version: '2026-05-01' // TODO @michaelliav: confirm Foundry-side version string + sku: { name: 'GlobalStandard', capacity: 100 } + contentFilter: 'medium' + } + { + name: 'claude-sonnet-4-6' + version: '2026-04-15' + sku: { name: 'GlobalStandard', capacity: 200 } + contentFilter: 'low' // SecurityReviewer needs low-filter to not false-positive on injection-review prompts + } + { + name: 'claude-haiku-4-5' + version: '2026-03-30' + sku: { name: 'GlobalStandard', capacity: 50 } + contentFilter: 'medium' + } +] + +// ────────────────────────────────────────────────────────────────────────────── +// 1. Hub + Project (existing — unchanged structurally, just adds tags) +// ────────────────────────────────────────────────────────────────────────────── + resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { name: 'fdry-${prefix}-${env}' location: location @@ -37,7 +116,130 @@ resource project 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { } } -// Diagnostic settings — every prompt/response logged +// ────────────────────────────────────────────────────────────────────────────── +// 2. Anthropic model deployments +// +// One deployment per pinned Anthropic version. Capacity is per-deployment in +// Tokens-Per-Minute units (GlobalStandard SKU); see `values.yaml` for runtime +// per-dev RPM caps enforced at the LiteLLM gateway. +// ────────────────────────────────────────────────────────────────────────────── + +resource anthropicDeployments 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = [for model in anthropicModels: { + name: '${hub.name}/${model.name}' + sku: model.sku + properties: { + model: { + format: 'Anthropic' + name: model.name + version: model.version + } + raiPolicyName: 'cf-${model.contentFilter}-policy' + versionUpgradeOption: 'NoAutoUpgrade' // we want explicit version pins + } + dependsOn: [ + acceptAnthropicTerms + ] +}] + +// ────────────────────────────────────────────────────────────────────────────── +// 3. Anthropic terms-of-use acceptance (Q3 above) +// +// Anthropic models on Foundry require a one-time legal acceptance per +// subscription. We do it inline via a deploymentScript so a fresh subscription +// can deploy this Bicep without an out-of-band manual step. +// +// TODO @michaelliav: confirm the API path. The script below uses the +// `cognitiveservices terms accept` REST call — replace with the canonical path +// if you know it. Idempotent: calling accept-terms after acceptance is a no-op. +// ────────────────────────────────────────────────────────────────────────────── + +resource acceptAnthropicTerms 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: 'accept-anthropic-terms-${env}' + location: location + kind: 'AzureCLI' + tags: tags + properties: { + azCliVersion: '2.65.0' + timeout: 'PT10M' + retentionInterval: 'PT1H' + cleanupPreference: 'OnSuccess' + scriptContent: ''' + set -e + echo "▶ Accepting Anthropic terms on Foundry hub: $HUB_NAME" + + # TODO @michaelliav: confirm the REST path. Tried two candidates: + # 1. POST /providers/Microsoft.CognitiveServices/locations/{loc}/models/anthropic/terms + # 2. POST /providers/Microsoft.MachineLearningServices/workspaces/{name}/marketplaceTerms/anthropic + # + # The portal flow hits #2 (verified in browser network tab on UAT). If you + # have the CLI command, replace this curl with `az` directly. + + ENDPOINT="https://management.azure.com/subscriptions/${SUB_ID}/resourceGroups/${RG_NAME}/providers/Microsoft.MachineLearningServices/workspaces/${HUB_NAME}/marketplaceTerms/anthropic?api-version=2024-10-01" + TOKEN=$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv) + + curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"properties":{"accepted":true}}' "$ENDPOINT" \ + || { echo "Terms acceptance failed — may already be accepted (HTTP 409 is OK)" ; exit 0 ; } + + echo "✓ Anthropic terms accepted (or already were)" + ''' + environmentVariables: [ + { name: 'HUB_NAME', value: hub.name } + { name: 'RG_NAME', value: resourceGroup().name } + { name: 'SUB_ID', value: subscription().subscriptionId } + ] + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// 4. Federated credential — model-gateway MI ↔ Kubernetes ServiceAccount +// +// The gateway pod runs under KSA `platform/model-gateway`. AKS's OIDC issuer +// signs JWTs for that SA; this federated credential makes Azure AD trust those +// JWTs and mint AAD access tokens for the gateway MI without any static creds. +// ────────────────────────────────────────────────────────────────────────────── + +resource gatewayFederation 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = { + name: '${last(split(gatewayIdentityId, '/'))}/k8s-model-gateway' + properties: { + issuer: aksOidcIssuerUrl + subject: gatewayKubernetesSubject + audiences: [ 'api://AzureADTokenExchange' ] + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// 5. RBAC — gateway MI → Foundry (Q1 above) +// +// Currently betting on `Cognitive Services User` (b32c94a3-3d2c-4c1a-9489-9c0e26f31ea4) +// scoped to the *project* — broad enough to issue inference tokens for any +// deployment under the project, narrow enough that the gateway can't admin the +// hub or other projects. +// +// TODO @michaelliav: is this the right scope? Specifically: +// - Does inference token issuance require role on the HUB (not project)? +// - Or do we need per-deployment role assignments (more verbose, tighter)? +// - Is `Azure AI Developer` (64702f94-c441-49e6-a78b-ef80e0188fee) needed +// instead/additionally for the post-deploy admin path? +// ────────────────────────────────────────────────────────────────────────────── + +@description('Built-in role: Cognitive Services User') +var cogServicesUserRoleId = 'a97b65f3-24c7-4388-baec-2e87135dc908' + +resource roleAssignmentGateway 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: project + name: guid(project.id, gatewayIdentityPrincipalId, cogServicesUserRoleId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cogServicesUserRoleId) + principalId: gatewayIdentityPrincipalId + principalType: 'ServicePrincipal' + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// 6. Diagnostic settings (existing) + audit-of-RBAC (new) +// ────────────────────────────────────────────────────────────────────────────── + resource hubDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { scope: hub name: 'allLogs' @@ -48,6 +250,12 @@ resource hubDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { } } +// ────────────────────────────────────────────────────────────────────────────── +// Outputs +// ────────────────────────────────────────────────────────────────────────────── + output hubId string = hub.id output projectId string = project.id output endpoint string = 'https://${hub.name}.${location}.api.azureml.ms' +output anthropicDeploymentNames array = [for (model, i) in anthropicModels: anthropicDeployments[i].name] +output gatewayFederationId string = gatewayFederation.id diff --git a/infra/modules/identity.bicep b/infra/modules/identity.bicep index 191169a..f2bb74c 100644 --- a/infra/modules/identity.bicep +++ b/infra/modules/identity.bicep @@ -16,9 +16,21 @@ resource agentId 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = tags: tags } +// model-gateway MI: the only identity that talks to Foundry directly. +// The agent-pod MI deliberately does NOT have Foundry permissions — agents +// only reach Foundry via the gateway, never directly. (See docs/SECURITY.md.) +resource gatewayId 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: 'id-${prefix}-gateway-${env}' + location: location + tags: tags +} + output routerIdentityId string = routerId.id output routerIdentityClientId string = routerId.properties.clientId output routerIdentityPrincipalId string = routerId.properties.principalId output agentIdentityId string = agentId.id output agentIdentityClientId string = agentId.properties.clientId output agentIdentityPrincipalId string = agentId.properties.principalId +output gatewayIdentityId string = gatewayId.id +output gatewayIdentityClientId string = gatewayId.properties.clientId +output gatewayIdentityPrincipalId string = gatewayId.properties.principalId From 3fb83201d134fefffbc8cb09e6c3144fec382bf0 Mon Sep 17 00:00:00 2001 From: Notorious Agent Date: Thu, 4 Jun 2026 21:48:08 +0300 Subject: [PATCH 2/8] infra(foundry): rewrite to AIServices account model + harden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ML-workspace (hub/project) starter with a single Microsoft.CognitiveServices/accounts (kind: AIServices) — the model the gateway and Helm chart actually expect. The old path emitted an api.azureml.ms endpoint that can't do keyless MI auth. - customSubDomainName + disableLocalAuth: mandatory for Entra/MI token auth - private-only (publicNetworkAccess Disabled, networkAcls Deny), PE group 'account' - Anthropic deployments as account children, GlobalStandard, @batchSize(1) - RBAC: Cognitive Services User, account-scoped, gateway MI only (issue #2 Q1) - remove raiPolicyName: Foundry applies no RAI policy to Claude (issue #2 Q2) - remove deploymentScripts curl hack: terms acceptance is a Marketplace prereq, not an ARM property; documented in OPERATIONS.md (issue #2 Q3) - main.bicep: drop unused keyVaultId/storageId wiring - region: westus3 -> eastus2 (Claude: eastus2/swedencentral only) Compiles clean (bicep build, zero new warnings). Model version strings are placeholders — confirm via `az cognitiveservices model list` pre-deploy. --- docs/OPERATIONS.md | 40 +++++- infra/main.bicep | 6 +- infra/main.bicepparam | 2 +- infra/modules/foundry.bicep | 257 ++++++++++++++++-------------------- 4 files changed, 153 insertions(+), 152 deletions(-) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 53cd185..21a422c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -2,6 +2,44 @@ > Day-2 stuff. P1 procedures, scaling, rotations, common SRE tasks. +## Prerequisites (one-time, per subscription) + +### Anthropic / Claude on Foundry — Marketplace terms acceptance + +Claude models are a **Marketplace SaaS offer**. Before `infra/modules/foundry.bicep` +can deploy the `claude-*` deployments, the offer's terms must be accepted **once +per subscription**. This is **not** an ARM property and **not** a callable REST +path — it cannot be automated inside the Bicep deploy (an earlier draft tried a +`deploymentScripts` curl hack; it was removed because no such stable endpoint +exists). Attempting to deploy the model before accepting terms fails with a +`MarketplacePurchaseEligibilityFailed` / `SkuNotAvailable` style error. + +Requirements: + +- **Subscription type**: Enterprise Agreement or MCA-E (pay-as-you-go/MSDN are + not eligible for the Anthropic offer). +- **Region**: `eastus2` or `swedencentral` only. The infra pins `eastus2`. + +Accept the terms once, before the first `az deployment sub create`: + +```bash +# Portal path: Foundry portal → Model catalog → Claude → "Agree & continue" +# on the Marketplace terms dialog (per subscription, one time). +# +# CLI path (if the offer is surfaced as an Azure Marketplace term): +az term accept \ + --publisher anthropic \ + --product anthropic-claude-foundry \ + --plan claude # confirm publisher/product/plan IDs in the portal first + +# Verify before deploying: +az term show --publisher anthropic --product anthropic-claude-foundry --plan claude \ + --query accepted -o tsv # → true +``` + +> If `az term` does not list the offer for your tenant, use the portal flow — +> it is the canonical path. Treat this as a hard gate in the deploy runbook. + ## On-call basics - **Pager**: Code Forge SRE rotation (PagerDuty service `code-forge-prod`). @@ -41,7 +79,7 @@ curl -H "x-litellm-api-key: $LITELLM_MASTER_KEY" \ 1. `kubectl -n platform logs deploy/model-gateway -c gateway --tail=100 | grep refresh-aad` 2. If "Failed to get token" → ServiceAccount federation broken. `az identity federated-credential list --identity-name codeforge-gateway-mi -g rg-codeforge` and confirm subject = `system:serviceaccount:platform:model-gateway`. -3. If federation is fine, check the MI has `Azure AI User` on the Foundry resource: `az role assignment list --assignee $GATEWAY_MI_PRINCIPAL_ID --scope $FOUNDRY_RESOURCE_ID`. +3. If federation is fine, check the MI has `Cognitive Services User` on the Foundry account: `az role assignment list --assignee $GATEWAY_MI_PRINCIPAL_ID --scope $FOUNDRY_RESOURCE_ID`. ### Foundry 429s climbing diff --git a/infra/main.bicep b/infra/main.bicep index 060baef..5c911bd 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -8,8 +8,8 @@ targetScope = 'subscription' @description('Environment short name (dev/stg/prod)') param env string = 'dev' -@description('Azure region') -param location string = 'westus3' +@description('Azure region — Claude on Foundry requires eastus2 or swedencentral') +param location string = 'eastus2' @description('Project prefix for naming') param prefix string = 'codeforge' @@ -154,8 +154,6 @@ module foundry 'modules/foundry.bicep' = { location: location tags: tags subnetIdPe: network.outputs.peSubnetId - keyVaultId: keyvault.outputs.kvId - storageId: storage.outputs.storageId logAnalyticsWorkspaceId: logging.outputs.workspaceId aksOidcIssuerUrl: aks.outputs.oidcIssuerUrl gatewayIdentityId: identity.outputs.gatewayIdentityId diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 0a7ddc8..de0d06a 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -1,7 +1,7 @@ using 'main.bicep' param env = 'dev' -param location = 'westus3' +param location = 'eastus2' // Claude on Foundry: eastus2 or swedencentral only param prefix = 'codeforge' // REPLACE with your platform-admins Entra group object id param adminGroupObjectId = '00000000-0000-0000-0000-000000000000' diff --git a/infra/modules/foundry.bicep b/infra/modules/foundry.bicep index c9507d5..eb433d5 100644 --- a/infra/modules/foundry.bicep +++ b/infra/modules/foundry.bicep @@ -1,202 +1,172 @@ -// Microsoft Foundry (AI Foundry) hub + project for Claude/OpenAI deployments +// Microsoft Foundry (AI Foundry) account + Anthropic/Claude model deployments // // ────────────────────────────────────────────────────────────────────────────── -// HARDENING STARTER (Hermes → @michaelliav, see issue #2) +// HARDENING (Hermes → @michaelliav, resolves issue #2) // ────────────────────────────────────────────────────────────────────────────── -// This file expands `infra/modules/foundry.bicep` from "Hub + Project only" to -// a complete Foundry stack: hub, project, model deployments, federated identity -// for the model-gateway, RBAC scoped to least privilege, and a deployment -// script that automates the Anthropic terms acceptance. +// Rewritten from the hub/project (ML workspace) starter to the CORRECT runtime +// model: a single `Microsoft.CognitiveServices/accounts` resource with +// `kind: 'AIServices'`. Claude on Foundry is served from this account's +// `https://.services.ai.azure.com/anthropic` endpoint — which is exactly +// what `charts/code-forge` already expects (see _helpers.tpl `foundryBaseUrl`). +// The previous ML-workspace model produced an `api.azureml.ms` endpoint the +// model-gateway can never authenticate against keylessly, so it is removed. // -// THREE OPEN QUESTIONS (Michael's domain — please confirm/correct in PR): +// THREE ISSUE-#2 QUESTIONS — RESOLVED (grounded in Microsoft Learn): // -// 1. Federated identity scope — where does the model-gateway's federated -// credential need RBAC? Options below; I'm currently betting on (b) for -// inference-token issuance but unsure if (a) is also required. -// (a) `Cognitive Services User` on the parent Hub -// (b) `Cognitive Services User` on each model deployment (resource-scoped) -// (c) `Azure AI Developer` on the project (broader; needed for hub APIs?) -// → see `roleAssignmentGateway` block below. +// 1. Federated-identity RBAC scope. +// → `Cognitive Services User` (a97b65f3-24c7-4388-baec-2e87135dc908) scoped +// to THE ACCOUNT. There is no hub/project anymore. This role lets the +// gateway MI issue inference tokens for every deployment under the account +// and nothing else (it cannot manage the account or other resources). +// Data-plane token scope is `https://ai.azure.com/.default`. +// Custom subdomain (set below) is MANDATORY for Entra/MI auth — regional +// endpoints do not support token auth. // -// 2. Content filter level — Foundry exposes content filters per deployment. -// The SecurityReviewer agent intentionally writes spicy code-injection -// review prompts, which can false-positive on `medium`/`high` filters and -// block legitimate workflow runs. Currently set to `low` for SecReviewer's -// Sonnet deployment, `medium` for everyone else. Defensible? Or do we need -// a custom blocklist policy attached? -// → see `contentFilterAssignments` array below. +// 2. Content-filter level for SecurityReviewer. +// → N/A at the infra layer. Foundry does NOT apply deployment-time RAI / +// content-filter policies to Anthropic models — Claude ships with +// Anthropic's own safety stack. `raiPolicyName` is a no-op for +// `format: 'Anthropic'`, so the whole content-filter block is REMOVED +// rather than tuned. If SecurityReviewer ever needs softer gating, do it +// at the LiteLLM gateway / prompt layer, not here. // -// 3. Anthropic terms acceptance — Anthropic models on Foundry require a -// one-time legal acceptance per subscription. ARM doesn't expose this as a -// property; it has to be done via the Foundry portal OR by calling the -// `cognitiveservices terms accept` API path. Below is a `deploymentScripts` -// resource that calls the API automatically. Two concerns: -// - is the API path stable / GA, or do we keep this as a manual one-time -// pre-req in `docs/OPERATIONS.md`? -// - should the script run on every deploy (idempotent? probably yes, -// accept-terms is a no-op if already accepted) or only on first run? -// → see `acceptAnthropicTerms` deploymentScript below. -// -// Reply on PR or on issue #2. — Hermes +// 3. Anthropic terms acceptance. +// → It is a Marketplace SaaS subscription prerequisite, NOT an ARM property +// or a callable REST path. The `deploymentScripts` curl hack is removed. +// Documented as a one-time pre-req in docs/OPERATIONS.md. Requires an +// Enterprise or MCA-E subscription with Marketplace access, in East US 2 +// or Sweden Central. // ────────────────────────────────────────────────────────────────────────────── param prefix string param env string + +@description('Must be a Claude-supported Foundry region: eastus2 or swedencentral.') +@allowed([ + 'eastus2' + 'swedencentral' +]) param location string + param tags object + +@description('PE subnet — the account is private-only, mirroring Key Vault / Storage.') param subnetIdPe string -param keyVaultId string -param storageId string -param logAnalyticsWorkspaceId string -// New parameters (Michael — please review naming/defaults): +@description('Log Analytics workspace for diagnostic settings.') +param logAnalyticsWorkspaceId string -@description('OIDC issuer URL of the AKS cluster — needed to federate the model-gateway MI to its KSA') +@description('OIDC issuer URL of the AKS cluster — federates the model-gateway MI to its KSA.') param aksOidcIssuerUrl string -@description('Resource ID of the user-assigned managed identity for the model-gateway') +@description('Resource ID of the user-assigned managed identity for the model-gateway.') param gatewayIdentityId string -@description('Principal ID of the gateway MI — used for RBAC role assignments') +@description('Principal ID of the gateway MI — used for the account-scoped role assignment.') param gatewayIdentityPrincipalId string -@description('Kubernetes namespace.serviceaccount that the federated credential trusts. Format: ns:platform/sa:model-gateway') +@description('Kubernetes namespace/serviceaccount the federated credential trusts.') param gatewayKubernetesSubject string = 'system:serviceaccount:platform:model-gateway' -@description('Anthropic models to deploy on Foundry. Pin versions explicitly — aliases break when Anthropic releases new versions.') +@description('Anthropic models to deploy. Pin versions explicitly — confirm the Foundry-side version strings with `az cognitiveservices model list -l ` before deploy.') param anthropicModels array = [ { name: 'claude-opus-4-8' - version: '2026-05-01' // TODO @michaelliav: confirm Foundry-side version string - sku: { name: 'GlobalStandard', capacity: 100 } - contentFilter: 'medium' + version: '2026-05-01' + capacity: 100 } { name: 'claude-sonnet-4-6' version: '2026-04-15' - sku: { name: 'GlobalStandard', capacity: 200 } - contentFilter: 'low' // SecurityReviewer needs low-filter to not false-positive on injection-review prompts + capacity: 200 } { name: 'claude-haiku-4-5' version: '2026-03-30' - sku: { name: 'GlobalStandard', capacity: 50 } - contentFilter: 'medium' + capacity: 50 } ] // ────────────────────────────────────────────────────────────────────────────── -// 1. Hub + Project (existing — unchanged structurally, just adds tags) +// 1. Foundry (AI Services) account +// +// `customSubDomainName` == account name is REQUIRED so the endpoint resolves to +// https://.services.ai.azure.com and so Entra/managed-identity (keyless) +// auth works at all. Private-only + key auth disabled = least privilege. +// The subdomain is globally unique; uniqueString keeps redeploys collision-free. +// Consumers wire the Helm value `global.foundry.resource` from the `accountName` +// output (or `global.foundry.baseUrl` from the `endpoint` output). // ────────────────────────────────────────────────────────────────────────────── -resource hub 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { - name: 'fdry-${prefix}-${env}' - location: location - tags: tags - kind: 'Hub' - identity: { type: 'SystemAssigned' } - properties: { - friendlyName: 'Code Forge Foundry ${env}' - keyVault: keyVaultId - storageAccount: storageId - publicNetworkAccess: 'Disabled' - managedNetwork: { isolationMode: 'AllowInternetOutbound' } - } -} +var accountName = take('fdry-${prefix}-${env}-${uniqueString(resourceGroup().id)}', 63) -resource project 'Microsoft.MachineLearningServices/workspaces@2024-10-01' = { - name: 'fdryproj-${prefix}-${env}' +resource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = { + name: accountName location: location tags: tags - kind: 'Project' + kind: 'AIServices' + sku: { name: 'S0' } identity: { type: 'SystemAssigned' } properties: { - friendlyName: 'Code Forge ${env}' - hubResourceId: hub.id + customSubDomainName: accountName + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + networkAcls: { + defaultAction: 'Deny' + bypass: 'AzureServices' + } } } // ────────────────────────────────────────────────────────────────────────────── -// 2. Anthropic model deployments +// 2. Anthropic model deployments (children of the account) // -// One deployment per pinned Anthropic version. Capacity is per-deployment in -// Tokens-Per-Minute units (GlobalStandard SKU); see `values.yaml` for runtime -// per-dev RPM caps enforced at the LiteLLM gateway. +// @batchSize(1): CognitiveServices deployments under one account must be applied +// serially or ARM throws conflicting-operation errors. No raiPolicyName — see Q2. // ────────────────────────────────────────────────────────────────────────────── +@batchSize(1) resource anthropicDeployments 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = [for model in anthropicModels: { - name: '${hub.name}/${model.name}' - sku: model.sku + parent: account + name: model.name + sku: { + name: 'GlobalStandard' + capacity: model.capacity + } properties: { model: { format: 'Anthropic' name: model.name version: model.version } - raiPolicyName: 'cf-${model.contentFilter}-policy' - versionUpgradeOption: 'NoAutoUpgrade' // we want explicit version pins + versionUpgradeOption: 'NoAutoUpgrade' } - dependsOn: [ - acceptAnthropicTerms - ] }] // ────────────────────────────────────────────────────────────────────────────── -// 3. Anthropic terms-of-use acceptance (Q3 above) -// -// Anthropic models on Foundry require a one-time legal acceptance per -// subscription. We do it inline via a deploymentScript so a fresh subscription -// can deploy this Bicep without an out-of-band manual step. -// -// TODO @michaelliav: confirm the API path. The script below uses the -// `cognitiveservices terms accept` REST call — replace with the canonical path -// if you know it. Idempotent: calling accept-terms after acceptance is a no-op. +// 3. Private endpoint (group: account) — mirrors keyvault.bicep house style. +// No inline DNS zone group; private DNS is managed centrally. // ────────────────────────────────────────────────────────────────────────────── -resource acceptAnthropicTerms 'Microsoft.Resources/deploymentScripts@2023-08-01' = { - name: 'accept-anthropic-terms-${env}' +resource peFoundry 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: 'pe-${account.name}' location: location - kind: 'AzureCLI' tags: tags properties: { - azCliVersion: '2.65.0' - timeout: 'PT10M' - retentionInterval: 'PT1H' - cleanupPreference: 'OnSuccess' - scriptContent: ''' - set -e - echo "▶ Accepting Anthropic terms on Foundry hub: $HUB_NAME" - - # TODO @michaelliav: confirm the REST path. Tried two candidates: - # 1. POST /providers/Microsoft.CognitiveServices/locations/{loc}/models/anthropic/terms - # 2. POST /providers/Microsoft.MachineLearningServices/workspaces/{name}/marketplaceTerms/anthropic - # - # The portal flow hits #2 (verified in browser network tab on UAT). If you - # have the CLI command, replace this curl with `az` directly. - - ENDPOINT="https://management.azure.com/subscriptions/${SUB_ID}/resourceGroups/${RG_NAME}/providers/Microsoft.MachineLearningServices/workspaces/${HUB_NAME}/marketplaceTerms/anthropic?api-version=2024-10-01" - TOKEN=$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv) - - curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"properties":{"accepted":true}}' "$ENDPOINT" \ - || { echo "Terms acceptance failed — may already be accepted (HTTP 409 is OK)" ; exit 0 ; } - - echo "✓ Anthropic terms accepted (or already were)" - ''' - environmentVariables: [ - { name: 'HUB_NAME', value: hub.name } - { name: 'RG_NAME', value: resourceGroup().name } - { name: 'SUB_ID', value: subscription().subscriptionId } - ] + subnet: { id: subnetIdPe } + privateLinkServiceConnections: [ { + name: 'plsc' + properties: { + privateLinkServiceId: account.id + groupIds: [ 'account' ] + } + } ] } } // ────────────────────────────────────────────────────────────────────────────── -// 4. Federated credential — model-gateway MI ↔ Kubernetes ServiceAccount -// -// The gateway pod runs under KSA `platform/model-gateway`. AKS's OIDC issuer -// signs JWTs for that SA; this federated credential makes Azure AD trust those -// JWTs and mint AAD access tokens for the gateway MI without any static creds. +// 4. Federated credential — model-gateway MI ↔ Kubernetes ServiceAccount. +// AKS OIDC-signed SA JWTs are exchanged for AAD tokens; no static creds. // ────────────────────────────────────────────────────────────────────────────── resource gatewayFederation 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = { @@ -209,26 +179,17 @@ resource gatewayFederation 'Microsoft.ManagedIdentity/userAssignedIdentities/fed } // ────────────────────────────────────────────────────────────────────────────── -// 5. RBAC — gateway MI → Foundry (Q1 above) -// -// Currently betting on `Cognitive Services User` (b32c94a3-3d2c-4c1a-9489-9c0e26f31ea4) -// scoped to the *project* — broad enough to issue inference tokens for any -// deployment under the project, narrow enough that the gateway can't admin the -// hub or other projects. -// -// TODO @michaelliav: is this the right scope? Specifically: -// - Does inference token issuance require role on the HUB (not project)? -// - Or do we need per-deployment role assignments (more verbose, tighter)? -// - Is `Azure AI Developer` (64702f94-c441-49e6-a78b-ef80e0188fee) needed -// instead/additionally for the post-deploy admin path? +// 5. RBAC — gateway MI → account (Q1). Cognitive Services User, account scope. +// The agent-pod MI is intentionally absent: agents reach Foundry only via the +// gateway, never directly (see docs/SECURITY.md). // ────────────────────────────────────────────────────────────────────────────── @description('Built-in role: Cognitive Services User') var cogServicesUserRoleId = 'a97b65f3-24c7-4388-baec-2e87135dc908' resource roleAssignmentGateway 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: project - name: guid(project.id, gatewayIdentityPrincipalId, cogServicesUserRoleId) + scope: account + name: guid(account.id, gatewayIdentityPrincipalId, cogServicesUserRoleId) properties: { roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cogServicesUserRoleId) principalId: gatewayIdentityPrincipalId @@ -237,11 +198,11 @@ resource roleAssignmentGateway 'Microsoft.Authorization/roleAssignments@2022-04- } // ────────────────────────────────────────────────────────────────────────────── -// 6. Diagnostic settings (existing) + audit-of-RBAC (new) +// 6. Diagnostic settings → Log Analytics // ────────────────────────────────────────────────────────────────────────────── -resource hubDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - scope: hub +resource accountDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: account name: 'allLogs' properties: { workspaceId: logAnalyticsWorkspaceId @@ -254,8 +215,12 @@ resource hubDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { // Outputs // ────────────────────────────────────────────────────────────────────────────── -output hubId string = hub.id -output projectId string = project.id -output endpoint string = 'https://${hub.name}.${location}.api.azureml.ms' +@description('AIServices account name — set as global.foundry.resource in Helm values.') +output accountName string = account.name + +@description('Anthropic base URL — matches charts/_helpers.tpl foundryBaseUrl. Set as global.foundry.baseUrl (optional override).') +output endpoint string = 'https://${account.name}.services.ai.azure.com/anthropic' + +output accountId string = account.id output anthropicDeploymentNames array = [for (model, i) in anthropicModels: anthropicDeployments[i].name] output gatewayFederationId string = gatewayFederation.id From f864f4b0801470af4baa9d2b5d321f25b02ba1d1 Mon Sep 17 00:00:00 2001 From: Notorious Agent Date: Sun, 7 Jun 2026 09:50:22 +0300 Subject: [PATCH 3/8] Add sandbox-orchestrator: request-per-sandbox service on agent-sandbox SDK Sandbox-native successor to the Go session-router. Instead of label-patching a warm agent pod, the orchestrator provisions one agent sandbox per request via the agent-sandbox Python SDK (create -> run -> teardown), with the controller-side TTL as a safety net. - FastAPI service (POST/GET/DELETE /v1/sandboxes, /healthz, /stats) - SandboxManager: admission control (global + per-dev caps), state machine, guaranteed teardown in finally, background record reaper - Backend abstraction with real SDK backend + in-memory fake for tests - 17 pytest cases covering full lifecycle, failure paths, concurrency caps (no cluster required via the fake backend) - Multi-stage non-root Dockerfile (read-only-rootfs friendly) - Helm template: Deployment/Service/SA/RBAC(scoped to sandbox CRDs)/NetworkPolicy + values entries + workload-identity SA - Python, not Go; aligns with the Python-first src/code_forge codebase --- .../templates/35-sandbox-orchestrator.yaml | 172 ++++++++++ charts/code-forge/values.yaml | 49 +++ containers/sandbox-orchestrator/.dockerignore | 9 + containers/sandbox-orchestrator/.gitignore | 7 + containers/sandbox-orchestrator/CLAUDE.md | 122 +++++++ containers/sandbox-orchestrator/Dockerfile | 50 +++ .../sandbox-orchestrator/pyproject.toml | 27 ++ .../sandbox-orchestrator/requirements-dev.txt | 4 + .../sandbox-orchestrator/requirements.txt | 10 + .../sandbox_orchestrator/__init__.py | 34 ++ .../sandbox_orchestrator/__main__.py | 28 ++ .../sandbox_orchestrator/api.py | 121 +++++++ .../sandbox_orchestrator/backends.py | 267 ++++++++++++++++ .../sandbox_orchestrator/config.py | 156 +++++++++ .../sandbox_orchestrator/manager.py | 268 ++++++++++++++++ .../sandbox_orchestrator/models.py | 152 +++++++++ .../tests/test_orchestrator.py | 299 ++++++++++++++++++ 17 files changed, 1775 insertions(+) create mode 100644 charts/code-forge/templates/35-sandbox-orchestrator.yaml create mode 100644 containers/sandbox-orchestrator/.dockerignore create mode 100644 containers/sandbox-orchestrator/.gitignore create mode 100644 containers/sandbox-orchestrator/CLAUDE.md create mode 100644 containers/sandbox-orchestrator/Dockerfile create mode 100644 containers/sandbox-orchestrator/pyproject.toml create mode 100644 containers/sandbox-orchestrator/requirements-dev.txt create mode 100644 containers/sandbox-orchestrator/requirements.txt create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/__init__.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/__main__.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/api.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/backends.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/config.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/manager.py create mode 100644 containers/sandbox-orchestrator/sandbox_orchestrator/models.py create mode 100644 containers/sandbox-orchestrator/tests/test_orchestrator.py diff --git a/charts/code-forge/templates/35-sandbox-orchestrator.yaml b/charts/code-forge/templates/35-sandbox-orchestrator.yaml new file mode 100644 index 0000000..646231c --- /dev/null +++ b/charts/code-forge/templates/35-sandbox-orchestrator.yaml @@ -0,0 +1,172 @@ +--- +# ============================================================================= +# Sandbox Orchestrator — sandbox-native successor to the session-router. +# +# Runs in the session-control plane. Instead of label-patching a warm agent pod, +# it provisions ONE agent sandbox per request via the agent-sandbox SDK +# (create → run → teardown). Its RBAC therefore targets the agent-sandbox CRDs +# in the sandbox namespace, NOT raw pods. +# ============================================================================= +{{- $so := .Values.sandboxOrchestrator }} +{{- $wi := .Values.workloadIdentity.sandboxOrchestrator }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $wi.serviceAccountName }} + namespace: {{ .Values.namespaces.sessionControl }} + annotations: + azure.workload.identity/client-id: "{{ $wi.clientId }}" + azure.workload.identity/tenant-id: "{{ .Values.global.azureTenantId }}" + labels: + azure.workload.identity/use: "true" + {{- include "code-forge.labels" . | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sandbox-orchestrator + namespace: {{ .Values.namespaces.sessionControl }} + labels: + app: sandbox-orchestrator + {{- include "code-forge.labels" . | nindent 4 }} +spec: + replicas: {{ $so.replicas }} + selector: { matchLabels: { app: sandbox-orchestrator } } + template: + metadata: + labels: + app: sandbox-orchestrator + azure.workload.identity/use: "true" + spec: + serviceAccountName: {{ $wi.serviceAccountName }} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: { type: RuntimeDefault } + containers: + - name: orchestrator + image: {{ .Values.global.registry }}/{{ $so.image.repository }}:{{ $so.image.tag }} + imagePullPolicy: {{ $so.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + ports: + - containerPort: 8080 + name: http + env: + - name: ORCHESTRATOR_PORT + value: "8080" + - name: ORCHESTRATOR_BACKEND + value: {{ $so.backend | quote }} + - name: SANDBOX_CONNECTION_MODE + value: {{ $so.connectionMode | quote }} + - name: SANDBOX_NAMESPACE + value: {{ $so.sandbox.namespace | quote }} + - name: SANDBOX_WARMPOOL + value: {{ $so.sandbox.warmpool | quote }} + - name: SANDBOX_TTL_SECONDS + value: {{ $so.sandbox.ttlSeconds | quote }} + - name: SANDBOX_COMMAND_TIMEOUT + value: {{ $so.sandbox.commandTimeout | quote }} + - name: MAX_CONCURRENT_SANDBOXES + value: {{ $so.concurrency.maxTotal | quote }} + - name: MAX_CONCURRENT_PER_DEV + value: {{ $so.concurrency.maxPerDev | quote }} + - name: AGENT_COMMAND_TEMPLATE + value: {{ $so.agentCommandTemplate | quote }} + - name: MODEL_GATEWAY_URL + value: "http://model-gateway.{{ .Values.namespaces.platform }}.svc.cluster.local" + resources: + {{- toYaml $so.resources | nindent 12 }} + readinessProbe: + httpGet: { path: /healthz, port: 8080 } + livenessProbe: + httpGet: { path: /healthz, port: 8080 } + initialDelaySeconds: 15 + # Orchestrator writes nothing to disk; readOnlyRootFilesystem is safe. + # uvicorn/Python only need a writable /tmp if a dep spills — mount one. + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: sandbox-orchestrator + namespace: {{ .Values.namespaces.sessionControl }} +spec: + selector: { app: sandbox-orchestrator } + ports: + - port: {{ $so.service.port }} + targetPort: 8080 + name: http +--- +# The orchestrator manages agent-sandbox CRDs (claim → run → delete) in the +# sandbox namespace. Scoped to exactly the verbs the SDK needs — no raw pod +# patching like the old session-router required. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: sandbox-orchestrator-sandbox-manager + namespace: {{ $so.sandbox.namespace }} +rules: + - apiGroups: [{{ $so.sandbox.apiGroup | quote }}] + resources: ["sandboxes", "sandboxclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [{{ $so.sandbox.apiGroup | quote }}] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["get", "list", "watch"] + # The SDK reads sandbox pod status / streams exec to run commands. + - apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/exec", "pods/portforward"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: sandbox-orchestrator-sandbox-manager + namespace: {{ $so.sandbox.namespace }} +subjects: + - kind: ServiceAccount + name: {{ $wi.serviceAccountName }} + namespace: {{ .Values.namespaces.sessionControl }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: sandbox-orchestrator-sandbox-manager +{{- if .Values.networkPolicies.enabled }} +--- +# Orchestrator egress: cluster DNS + Kubernetes API only. It talks to the +# kube-apiserver (via the SDK) to drive sandboxes; it does not call Foundry +# directly (agent work happens inside the sandboxes, behind their own policy). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: sandbox-orchestrator-egress + namespace: {{ .Values.namespaces.sessionControl }} +spec: + podSelector: + matchLabels: { app: sandbox-orchestrator } + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: {} + podSelector: + matchLabels: { k8s-app: kube-dns } + ports: + - { protocol: UDP, port: 53 } + - { protocol: TCP, port: 53 } + # kube-apiserver (HTTPS). Restrict to the API server endpoint at the + # firewall/private-link layer; NetworkPolicy can't select the API by IP here. + - ports: + - { protocol: TCP, port: 443 } + - { protocol: TCP, port: 6443 } +{{- end }} diff --git a/charts/code-forge/values.yaml b/charts/code-forge/values.yaml index c102bff..29d606b 100644 --- a/charts/code-forge/values.yaml +++ b/charts/code-forge/values.yaml @@ -129,6 +129,52 @@ sessionRouter: targetPendingRequests: 50 # buffer up to 50 in flight before scaling scaledownPeriod: 300 +# ------------------------------------------------------------------------- +# Sandbox Orchestrator +# Sandbox-native successor to the session-router. Receives a request and +# provisions ONE agent sandbox per request via the agent-sandbox SDK +# (create → run → teardown), instead of label-patching a warm pod. +# ------------------------------------------------------------------------- +sandboxOrchestrator: + image: + repository: code-forge/sandbox-orchestrator + tag: 0.1.0 + pullPolicy: IfNotPresent + + replicas: 2 + resources: + requests: { cpu: "250m", memory: "256Mi" } + limits: { cpu: "1000m", memory: "1Gi" } + + service: + port: 8080 + + # Backend: "sdk" (real agent-sandbox client) or "fake" (in-memory; tests only). + backend: sdk + # SDK connection architecture: in-cluster | local-tunnel | gateway | direct. + connectionMode: in-cluster + + sandbox: + # Namespace the Sandbox/SandboxClaim/SandboxWarmPool resources live in. + namespace: agent-sandboxes + # Default warm pool a request claims from when it doesn't name one. + warmpool: python-sandbox-warmpool + # Controller-side TTL (SandboxClaim shutdownTime). Safety net if we crash. + ttlSeconds: 3600 + # Per-command execution timeout inside the sandbox. + commandTimeout: 300 + # API group of the installed agent-sandbox CRDs. MUST match the CRDs on the + # cluster — used to scope the orchestrator's RBAC Role. + apiGroup: agents.x-k8s.io + + # Admission caps — exceeding either returns HTTP 429 (carried from the router). + concurrency: + maxTotal: 100 + maxPerDev: 3 + + # How a free-text task becomes a sandbox command. {task} is shell-quoted. + agentCommandTemplate: "claude -p {task}" + # ------------------------------------------------------------------------- # Model Gateway (LiteLLM) # Single proxy for all Foundry traffic. Per-dev rate limits, cost showback, @@ -173,6 +219,9 @@ workloadIdentity: sessionRouter: serviceAccountName: session-router clientId: "" + sandboxOrchestrator: + serviceAccountName: sandbox-orchestrator + clientId: "" modelGateway: serviceAccountName: model-gateway clientId: "" diff --git a/containers/sandbox-orchestrator/.dockerignore b/containers/sandbox-orchestrator/.dockerignore new file mode 100644 index 0000000..1371af1 --- /dev/null +++ b/containers/sandbox-orchestrator/.dockerignore @@ -0,0 +1,9 @@ +# Local dev / image hygiene +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +tests/ +requirements-dev.txt +.gitignore diff --git a/containers/sandbox-orchestrator/.gitignore b/containers/sandbox-orchestrator/.gitignore new file mode 100644 index 0000000..c5e9c1b --- /dev/null +++ b/containers/sandbox-orchestrator/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +build/ +dist/ diff --git a/containers/sandbox-orchestrator/CLAUDE.md b/containers/sandbox-orchestrator/CLAUDE.md new file mode 100644 index 0000000..375f0f2 --- /dev/null +++ b/containers/sandbox-orchestrator/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md — Sandbox Orchestrator + +> Loaded when `claude` runs in `containers/sandbox-orchestrator/`. + +## What this is + +The **sandbox-native successor to the Go `session-router`**. Where the router +listed warm pods labeled `app=agent-pod,state=warm`, patched one to +`state=bound`, and ran a reaper to recycle it, this service talks to the +upstream [agent-sandbox](https://agent-sandbox.sigs.k8s.io) project instead: + +- A request comes in on `POST /v1/sandboxes` with `{dev_id, project_id, task}`. +- The orchestrator **creates/claims an agent Sandbox** from a `SandboxWarmPool` + via the `k8s-agent-sandbox` Python SDK (`SandboxClient.create_sandbox`). +- It stages any input files, runs the agent command inside the sandbox + (`sandbox.commands.run`), captures the result, and **always terminates** the + sandbox in a `finally` (the controller-side TTL is the second safety net). +- The request record (state machine + result) is queryable on + `GET /v1/sandboxes/{id}` until the reaper evicts it. + +The win over warm-pod label-patching: lifecycle, isolation, and TTL are owned +by the agent-sandbox controller (a real CRD with status conditions), not by our +own bespoke reaper racing label patches against the kubelet. + +## Why Python (not Go) + +The session-router was Go. This service is Python because (a) the rest of the +app under `src/code_forge/` is Python, and (b) agent-sandbox ships a first-class +Python client. One language for the app, one obvious SDK. + +## File layout + +``` +sandbox_orchestrator/ + __main__.py # `python -m sandbox_orchestrator` → uvicorn boot + api.py # FastAPI app factory + HTTP surface (lifespan-managed) + manager.py # SandboxManager: admission control + lifecycle + reaper + backends.py # SandboxBackend abstraction: real SDK + FakeSandboxBackend + models.py # SandboxRequest / SandboxRecord / RequestState / ExecResult + config.py # env-driven Config (no static secrets — workload identity) +tests/ + test_orchestrator.py # full lifecycle vs the fake backend (no cluster) +Dockerfile # multi-stage, non-root UID 1000, read-only-rootfs friendly +requirements.txt # fastapi, uvicorn, k8s-agent-sandbox +``` + +The chart resources (Deployment/Service/SA/RBAC/NetworkPolicy) live in +`charts/code-forge/templates/35-sandbox-orchestrator.yaml`, not here — same +convention as the model-gateway. + +## HTTP surface + +| Method | Path | Purpose | +|--------|--------------------------|------------------------------------------| +| GET | `/healthz` | liveness/readiness (no backend calls) | +| GET | `/stats` | live concurrency + record counts | +| POST | `/v1/sandboxes` | provision → run → teardown; returns record | +| GET | `/v1/sandboxes` | list records (`?dev_id=` filter) | +| GET | `/v1/sandboxes/{id}` | fetch one record | +| DELETE | `/v1/sandboxes/{id}` | cancel/terminate a request | + +`POST` is synchronous-by-design: it blocks until the sandbox run finishes, then +returns the full result. The blocking SDK calls run in a threadpool so the event +loop stays responsive; concurrency is bounded by the admission caps below. + +## Build & run + +```bash +# Unit tests — no cluster, no SDK needed (fake backend): +python -m venv .venv && source .venv/bin/activate +pip install -r requirements-dev.txt +pytest -q + +# Local run against the fake backend (no Kubernetes): +ORCHESTRATOR_BACKEND=fake python -m sandbox_orchestrator +curl -s localhost:8080/v1/sandboxes -d '{"dev_id":"me","task":"write a fn"}' | jq + +# Container: +docker build -t code-forge/sandbox-orchestrator:dev . +``` + +## Configuration (env) + +| Var | Default | Meaning | +|------------------------------|----------------|-------------------------------------------| +| `ORCHESTRATOR_BACKEND` | `sdk` | `sdk` (real SDK) or `fake` (in-mem) | +| `SANDBOX_CONNECTION_MODE` | `in-cluster` | SDK connection config selector | +| `SANDBOX_NAMESPACE` | `agent-sandboxes` | namespace the sandboxes live in | +| `SANDBOX_WARMPOOL` | `python-sandbox-warmpool` | default `SandboxWarmPool` to claim from | +| `SANDBOX_TTL_SECONDS` | `3600` | controller-side sandbox TTL (safety net) | +| `SANDBOX_COMMAND_TIMEOUT` | `300` | per-run command timeout | +| `AGENT_COMMAND_TEMPLATE` | `claude -p {task}` | how a free-text task becomes a command | +| `MAX_CONCURRENT_SANDBOXES` | `100` | global admission cap → HTTP 429 | +| `MAX_CONCURRENT_PER_DEV` | `3` | per-dev admission cap → HTTP 429 | + +## Editing rules + +1. **Never add static cluster credentials.** The SDK authenticates in-cluster + via the pod ServiceAccount. If you reach for a kubeconfig secret, you've + broken the SA wiring — fix the RBAC, don't paper over it. +2. **Always terminate in `finally`.** A request that provisions a sandbox must + tear it down even on crash. The TTL is a backstop, not the primary path. +3. **Shell-quote anything from the request.** `derive_command` runs + `shlex.quote` on the task before substituting into `AGENT_COMMAND_TEMPLATE`. + Never string-concat request input into a command. (There's a test for this.) +4. **Keep the fake backend in lockstep with the real one.** Every method on + `SandboxBackend` must have a fake impl, or the tests stop proving the logic. + +## Pitfalls + +- **`429` on every request** → an admission cap is set too low, or slots are + leaking. Check `GET /stats`; `active_total` should drop back to 0 once a run + finishes. A non-zero idle `active_total` means a `finally` release was skipped. +- **`create_sandbox` hangs** → the `SandboxWarmPool` is empty or the controller + can't schedule. The SDK blocks until the Sandbox reaches `Ready`. Tune the + warmpool size or the command/connect timeouts. +- **SDK import error at boot** → `k8s-agent-sandbox` isn't installed. Tests + don't need it (fake backend), but the real image does — it's in + `requirements.txt`. `ORCHESTRATOR_BACKEND=fake` bypasses the import entirely. +- **`readOnlyRootFilesystem` write errors** → something is writing to disk. The + service shouldn't; if a dep needs a scratch dir, mount an `emptyDir` at + `/tmp` in the chart rather than relaxing the securityContext. diff --git a/containers/sandbox-orchestrator/Dockerfile b/containers/sandbox-orchestrator/Dockerfile new file mode 100644 index 0000000..f92c0c4 --- /dev/null +++ b/containers/sandbox-orchestrator/Dockerfile @@ -0,0 +1,50 @@ +# ============================================================================= +# Sandbox Orchestrator — request-per-sandbox front door for Code Forge. +# +# Provisions an agent sandbox per request via the upstream agent-sandbox Python +# SDK (k8s-agent-sandbox), runs the work inside it, tears it down. Sandbox-native +# successor to the Go session-router. +# +# Multi-stage: build a wheelhouse in a fat layer, install into a slim runtime. +# Runs as non-root (UID 1000) with a read-only root FS in-cluster — matches the +# securityContext the Helm chart enforces. +# ============================================================================= +FROM python:3.12-slim AS builder + +WORKDIR /build +COPY requirements.txt . +# Pre-build wheels so the runtime stage stays free of build toolchains. +RUN pip install --no-cache-dir --upgrade pip wheel \ + && pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt + +FROM python:3.12-slim AS runtime + +# OCI labels for provenance. +LABEL org.opencontainers.image.title="code-forge/sandbox-orchestrator" \ + org.opencontainers.image.description="Request-per-sandbox orchestrator built on the agent-sandbox SDK." \ + org.opencontainers.image.source="https://github.com/ZaltaClaw/code-forge-workflow" + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# Install deps from the prebuilt wheelhouse (no network, no compilers). +COPY --from=builder /wheels /wheels +COPY requirements.txt . +RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ + && rm -rf /wheels + +# App code. +COPY sandbox_orchestrator ./sandbox_orchestrator + +# Drop to an unprivileged user. The chart additionally enforces +# readOnlyRootFilesystem + drop ALL caps; nothing here writes to disk. +RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin orchestrator +USER 1000 + +EXPOSE 8080 + +# Liveness/readiness target for the kubelet probes is GET /healthz (see chart). +ENTRYPOINT ["python", "-m", "sandbox_orchestrator"] diff --git a/containers/sandbox-orchestrator/pyproject.toml b/containers/sandbox-orchestrator/pyproject.toml new file mode 100644 index 0000000..436db40 --- /dev/null +++ b/containers/sandbox-orchestrator/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "sandbox-orchestrator" +version = "0.1.0" +description = "Request-per-sandbox orchestrator for Code Forge, built on the agent-sandbox SDK." +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110,<1.0", + "uvicorn[standard]>=0.27,<1.0", + "k8s-agent-sandbox>=0.1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "httpx>=0.27"] + +[project.scripts] +sandbox-orchestrator = "sandbox_orchestrator.__main__:main" + +[tool.setuptools.packages.find] +include = ["sandbox_orchestrator*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/containers/sandbox-orchestrator/requirements-dev.txt b/containers/sandbox-orchestrator/requirements-dev.txt new file mode 100644 index 0000000..2be7f39 --- /dev/null +++ b/containers/sandbox-orchestrator/requirements-dev.txt @@ -0,0 +1,4 @@ +# Test + lint dependencies (not shipped in the runtime image). +-r requirements.txt +pytest>=8.0 +httpx>=0.27 # required by fastapi.testclient diff --git a/containers/sandbox-orchestrator/requirements.txt b/containers/sandbox-orchestrator/requirements.txt new file mode 100644 index 0000000..b695eb9 --- /dev/null +++ b/containers/sandbox-orchestrator/requirements.txt @@ -0,0 +1,10 @@ +# Sandbox Orchestrator — runtime dependencies +# +# Pinned to compatible ranges; the agent-sandbox SDK is the only non-obvious one. +fastapi>=0.110,<1.0 +uvicorn[standard]>=0.27,<1.0 + +# Upstream agent-sandbox Python client. Provides SandboxClient + connection +# configs (in-cluster / gateway / local-tunnel / direct). Installed in the +# image; the test-suite stubs it out via the fake backend so CI needs no cluster. +k8s-agent-sandbox>=0.1.0 diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/__init__.py b/containers/sandbox-orchestrator/sandbox_orchestrator/__init__.py new file mode 100644 index 0000000..e9cf0d1 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/__init__.py @@ -0,0 +1,34 @@ +"""Sandbox Orchestrator — request-per-sandbox front door for Code Forge. + +This service is the sandbox-native successor to the Go ``session-router``. +Where the router maps ``(dev_id, project_id)`` onto a *warm pod* and patches its +labels (``warm → bound → cooldown``), the orchestrator instead provisions a +fresh **agent sandbox per request** via the upstream +`agent-sandbox `_ project's Python SDK +(``k8s-agent-sandbox``), runs the requested work inside it, and tears it down. + +Why sandboxes instead of label-patched pods: + +* **Strong per-request isolation.** Every request gets its own pod-backed + sandbox with its own filesystem and lifecycle. No shared mutable workspace, + no label race between concurrent binds. +* **Native lifecycle.** The agent-sandbox controller owns warm pooling, + readiness, TTL shutdown, and cleanup. The orchestrator just claims, runs, + and terminates — no bespoke reaper logic patching ``state=cooldown``. +* **Same guarantees, less surface.** Budgets, per-dev caps, and the + default-deny network posture from the router carry over; the warm-pod state + machine does not. + +The package is intentionally split so the HTTP layer, the lifecycle manager, +and the sandbox backend are independently testable. The backend is an +interface (:class:`sandbox_orchestrator.backends.SandboxBackend`) with two +implementations: a thin adapter over the real SDK and an in-memory fake used by +the test-suite, so the full request lifecycle can be exercised without a live +Kubernetes cluster. +""" + +from __future__ import annotations + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/__main__.py b/containers/sandbox-orchestrator/sandbox_orchestrator/__main__.py new file mode 100644 index 0000000..252c2a5 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/__main__.py @@ -0,0 +1,28 @@ +"""``python -m sandbox_orchestrator`` entrypoint. + +Boots uvicorn with the env-driven :class:`Config`. The Helm Deployment runs +exactly this. +""" + +from __future__ import annotations + +import logging + +import uvicorn + +from .api import create_app +from .config import Config + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + config = Config.from_env() + app = create_app(config) + uvicorn.run(app, host=config.host, port=config.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/api.py b/containers/sandbox-orchestrator/sandbox_orchestrator/api.py new file mode 100644 index 0000000..a331cee --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/api.py @@ -0,0 +1,121 @@ +"""FastAPI application — the orchestrator's HTTP front door. + +Endpoints (mirrors the session-router's surface, sandbox-native semantics): + +* ``GET /healthz`` — liveness/readiness probe (no backend calls). +* ``GET /stats`` — current concurrency + record counts. +* ``POST /v1/sandboxes`` — submit a request; provision a sandbox, run, return. +* ``GET /v1/sandboxes`` — list request records (optional ``?dev_id=``). +* ``GET /v1/sandboxes/{id}`` — fetch one record. +* ``DELETE /v1/sandboxes/{id}`` — cancel/terminate a request. + +The blocking lifecycle (``manager.handle_request``) is dispatched to a +threadpool so the event loop stays responsive under concurrent load. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Query, Request, Response +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import JSONResponse + +from .backends import build_backend +from .config import Config +from .manager import CapacityError, NotFoundError, SandboxManager +from .models import SandboxRequest + +logger = logging.getLogger("sandbox_orchestrator.api") + + +def create_app(config: Config | None = None, manager: SandboxManager | None = None) -> FastAPI: + """Application factory. + + ``config``/``manager`` are injectable so tests can wire a fake-backed + manager without environment variables or a real cluster. + """ + config = config or Config.from_env() + if manager is None: + backend = build_backend(config) + manager = SandboxManager(config, backend) + + @asynccontextmanager + async def lifespan(app: FastAPI): + manager.start_reaper() + logger.info( + "orchestrator up: backend=%s connection_mode=%s namespace=%s", + config.backend, config.connection_mode, config.sandbox_namespace, + ) + try: + yield + finally: + manager.stop_reaper() + + app = FastAPI( + title="Code Forge — Sandbox Orchestrator", + version="0.1.0", + summary="Provisions an agent sandbox per request via the agent-sandbox SDK.", + lifespan=lifespan, + ) + app.state.config = config + app.state.manager = manager + + # -- probes ----------------------------------------------------------- + + @app.get("/healthz") + def healthz() -> dict: + return {"status": "ok"} + + @app.get("/stats") + def stats() -> dict: + return manager.stats() + + # -- core API --------------------------------------------------------- + + @app.post("/v1/sandboxes") + async def create_sandbox(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return _error(400, "invalid JSON body") + try: + req = SandboxRequest.from_dict(body) + except ValueError as exc: + return _error(400, str(exc)) + + try: + record = await run_in_threadpool(manager.handle_request, req) + except CapacityError as exc: + return _error(429, str(exc)) + + status = 201 if record.state.value in {"succeeded"} else 200 + return JSONResponse(status_code=status, content=record.to_public_dict()) + + @app.get("/v1/sandboxes") + def list_sandboxes(dev_id: str | None = Query(default=None)) -> dict: + records = manager.list_records(dev_id=dev_id) + return {"sandboxes": [r.to_public_dict() for r in records], "count": len(records)} + + @app.get("/v1/sandboxes/{request_id}") + def get_sandbox(request_id: str) -> Response: + try: + record = manager.get(request_id) + except NotFoundError: + return _error(404, f"unknown request_id: {request_id}") + return JSONResponse(content=record.to_public_dict()) + + @app.delete("/v1/sandboxes/{request_id}") + def cancel_sandbox(request_id: str) -> Response: + try: + record = manager.cancel(request_id) + except NotFoundError: + return _error(404, f"unknown request_id: {request_id}") + return JSONResponse(content=record.to_public_dict()) + + return app + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"error": message}) diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py b/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py new file mode 100644 index 0000000..5ccf437 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py @@ -0,0 +1,267 @@ +"""Sandbox backend abstraction. + +The orchestrator talks to sandboxes exclusively through :class:`SandboxBackend`. +This keeps the lifecycle manager (``manager.py``) free of SDK details and lets +the test-suite swap in :class:`FakeSandboxBackend` to exercise the full request +lifecycle with no Kubernetes cluster, no port-forwards, and no network. + +Two implementations: + +* :class:`SdkSandboxBackend` — thin adapter over the upstream + ``k8s-agent-sandbox`` ``SandboxClient``. Imported lazily so this module (and + the tests) load even when the SDK isn't installed. +* :class:`FakeSandboxBackend` — deterministic in-memory simulation. + +The contract is deliberately tiny — claim, run, write files, terminate — which +is exactly the slice of the SDK the orchestrator needs. +""" + +from __future__ import annotations + +import abc +import shlex +import uuid +from dataclasses import dataclass +from typing import Any, Callable + +from .config import Config +from .models import ExecResult + + +@dataclass +class SandboxHandle: + """Opaque reference to a provisioned sandbox. + + ``sandbox_id`` / ``claim_name`` are surfaced for audit + correlation; + ``_native`` carries the backend-specific object (a real SDK ``Sandbox`` or + a fake) and must not be inspected outside the backend that created it. + """ + + sandbox_id: str + claim_name: str + # Backend-specific object (real SDK ``Sandbox`` or ``_FakeSandbox``). + # Typed ``Any`` on purpose — only the owning backend may touch it. + _native: Any = None + + +class SandboxBackend(abc.ABC): + """Provision, drive, and tear down agent sandboxes.""" + + @abc.abstractmethod + def create_sandbox( + self, + *, + warmpool: str, + labels: dict[str, str] | None = None, + ttl_seconds: int | None = None, + ) -> SandboxHandle: + """Claim a sandbox from ``warmpool`` and block until it is Ready. + + Raises on timeout or provisioning failure; the caller is responsible + for marking the request FAILED. + """ + + @abc.abstractmethod + def write_file(self, handle: SandboxHandle, path: str, content: str) -> None: + """Write ``content`` to ``path`` inside the sandbox before the run.""" + + @abc.abstractmethod + def run(self, handle: SandboxHandle, command: str, timeout: int) -> ExecResult: + """Execute ``command`` inside the sandbox and return its result.""" + + @abc.abstractmethod + def terminate(self, handle: SandboxHandle) -> None: + """Permanently delete the sandbox. Must be idempotent.""" + + +# --------------------------------------------------------------------------- +# Real SDK adapter +# --------------------------------------------------------------------------- + + +class SdkSandboxBackend(SandboxBackend): + """Adapter over the upstream ``k8s-agent-sandbox`` SandboxClient. + + The connection config is derived from :class:`Config.connection_mode` to + match the SDK's four documented architectures. In production the + orchestrator runs *inside* the cluster, so ``in-cluster`` (direct + pod-to-pod) is the default and avoids the router hop entirely. + """ + + def __init__(self, config: Config): + self._config = config + self._client = self._build_client(config) + + @staticmethod + def _build_client(config: Config): + # Imported lazily so the module loads without the SDK present. + from k8s_agent_sandbox import SandboxClient + from k8s_agent_sandbox.models import ( + SandboxDirectConnectionConfig, + SandboxGatewayConnectionConfig, + SandboxInClusterConnectionConfig, + SandboxLocalTunnelConnectionConfig, + ) + + mode = config.connection_mode + if mode == "in-cluster": + conn = SandboxInClusterConnectionConfig(server_port=config.sandbox_server_port) + elif mode == "gateway": + conn = SandboxGatewayConnectionConfig( + gateway_name=config.gateway_name, + gateway_namespace=config.gateway_namespace, + server_port=config.sandbox_server_port, + ) + elif mode == "direct": + conn = SandboxDirectConnectionConfig( + api_url=config.router_api_url, + server_port=config.sandbox_server_port, + ) + else: # local-tunnel + conn = SandboxLocalTunnelConnectionConfig( + server_port=config.sandbox_server_port, + router_namespace=config.router_namespace, + ) + # cleanup=False: the orchestrator owns teardown explicitly per request + # rather than relying on the atexit hook (it's a long-running service). + return SandboxClient(connection_config=conn, cleanup=False) + + def create_sandbox( + self, + *, + warmpool: str, + labels: dict[str, str] | None = None, + ttl_seconds: int | None = None, + ) -> SandboxHandle: + kwargs: dict = { + "warmpool": warmpool, + "namespace": self._config.sandbox_namespace, + "sandbox_ready_timeout": self._config.sandbox_ready_timeout, + } + if labels: + kwargs["labels"] = labels + ttl = ttl_seconds if ttl_seconds is not None else self._config.sandbox_ttl_seconds + if ttl is not None and ttl > 0: + # SDK keyword-only arg: controller auto-deletes the claim on expiry, + # so a crashed orchestrator can never leak a sandbox. + kwargs["shutdown_after_seconds"] = ttl + + sandbox = self._client.create_sandbox(**kwargs) + return SandboxHandle( + sandbox_id=getattr(sandbox, "sandbox_id", ""), + claim_name=getattr(sandbox, "claim_name", ""), + _native=sandbox, + ) + + def write_file(self, handle: SandboxHandle, path: str, content: str) -> None: + sandbox = handle._native + # The SDK exposes a Filesystem engine via ``sandbox.files``. + sandbox.files.write(path, content) + + def run(self, handle: SandboxHandle, command: str, timeout: int) -> ExecResult: + sandbox = handle._native + result = sandbox.commands.run(command, timeout=timeout) + return ExecResult( + stdout=getattr(result, "stdout", "") or "", + stderr=getattr(result, "stderr", "") or "", + exit_code=int(getattr(result, "exit_code", -1)), + ) + + def terminate(self, handle: SandboxHandle) -> None: + sandbox = handle._native + if sandbox is None: + return + # SDK terminate() is idempotent and swallows 404s. + sandbox.terminate() + + +# --------------------------------------------------------------------------- +# In-memory fake (tests + cluster-free smoke) +# --------------------------------------------------------------------------- + + +class _FakeSandbox: + """A simulated sandbox with a virtual filesystem and a command hook.""" + + def __init__(self, sandbox_id: str, claim_name: str, runner: Callable[[str, dict[str, str]], ExecResult]): + self.sandbox_id = sandbox_id + self.claim_name = claim_name + self.files: dict[str, str] = {} + self.terminated = False + self._runner = runner + + def run(self, command: str, timeout: int) -> ExecResult: + if self.terminated: + raise RuntimeError("sandbox already terminated") + return self._runner(command, self.files) + + +class FakeSandboxBackend(SandboxBackend): + """Deterministic in-memory backend. + + By default every command "succeeds" with an echo of itself. Tests can + inject a custom ``runner`` to simulate non-zero exits, specific stdout, or + provisioning failures (via ``fail_on_create`` / ``fail_warmpools``). + """ + + def __init__( + self, + *, + runner: Callable[[str, dict[str, str]], ExecResult] | None = None, + fail_warmpools: set[str] | None = None, + ): + self.created: list[_FakeSandbox] = [] + self.terminated_ids: list[str] = [] + self._runner = runner or self._default_runner + self._fail_warmpools = fail_warmpools or set() + + @staticmethod + def _default_runner(command: str, files: dict[str, str]) -> ExecResult: + return ExecResult(stdout=f"ran: {command}\n", stderr="", exit_code=0) + + def create_sandbox( + self, + *, + warmpool: str, + labels: dict[str, str] | None = None, + ttl_seconds: int | None = None, + ) -> SandboxHandle: + if warmpool in self._fail_warmpools: + raise RuntimeError(f"warmpool {warmpool!r} has no ready sandboxes") + sid = f"sbx-{uuid.uuid4().hex[:8]}" + claim = f"sandbox-claim-{uuid.uuid4().hex[:8]}" + sandbox = _FakeSandbox(sid, claim, self._runner) + self.created.append(sandbox) + return SandboxHandle(sandbox_id=sid, claim_name=claim, _native=sandbox) + + def write_file(self, handle: SandboxHandle, path: str, content: str) -> None: + handle._native.files[path] = content + + def run(self, handle: SandboxHandle, command: str, timeout: int) -> ExecResult: + return handle._native.run(command, timeout) + + def terminate(self, handle: SandboxHandle) -> None: + sandbox = handle._native + if sandbox is None or sandbox.terminated: + return + sandbox.terminated = True + self.terminated_ids.append(sandbox.sandbox_id) + + +def build_backend(config: Config) -> SandboxBackend: + """Factory: pick the backend named by ``config.backend``.""" + if config.backend == "fake": + return FakeSandboxBackend() + return SdkSandboxBackend(config) + + +def derive_command(config: Config, *, task: str, command: str) -> str: + """Resolve the shell command to run inside the sandbox. + + Explicit ``command`` wins; otherwise the natural-language ``task`` is + shell-quoted and substituted into ``config.agent_command_template``. + """ + if command.strip(): + return command + safe_task = shlex.quote(task) + return config.agent_command_template.format(task=safe_task) diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/config.py b/containers/sandbox-orchestrator/sandbox_orchestrator/config.py new file mode 100644 index 0000000..c616951 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/config.py @@ -0,0 +1,156 @@ +"""Environment-driven configuration for the sandbox orchestrator. + +Every knob is an env var so the service stays 12-factor and the Helm chart can +drive it via a ConfigMap (mirroring ``session-router``'s ``router-config``). +Nothing here reads secrets — credentials come from Azure Workload Identity at +runtime, never from env (see ``docs/SECURITY.md``). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except ValueError as exc: # pragma: no cover - defensive + raise ValueError(f"{name} must be an integer, got {raw!r}") from exc + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +# Connection modes mirror the SDK's four architectures (see the client README). +# in-cluster → connect straight to the sandbox pod (orchestrator runs in-cluster) +# local-tunnel → kubectl port-forward to the sandbox-router (local dev / CI) +# gateway → production GKE/AKS Gateway discovery +# direct → explicit router api_url +VALID_CONNECTION_MODES = {"in-cluster", "local-tunnel", "gateway", "direct"} + +# Backends: +# sdk → real k8s-agent-sandbox SandboxClient (requires a cluster + SDK installed) +# fake → in-memory simulation (tests, local smoke without a cluster) +VALID_BACKENDS = {"sdk", "fake"} + + +@dataclass(frozen=True) +class Config: + """Resolved runtime configuration. Build with :meth:`from_env`.""" + + # --- HTTP ------------------------------------------------------------- + host: str = "0.0.0.0" + port: int = 8080 + + # --- Backend selection ----------------------------------------------- + backend: str = "sdk" + connection_mode: str = "in-cluster" + + # --- Sandbox provisioning -------------------------------------------- + # Namespace the SandboxClaim / SandboxWarmPool live in. + sandbox_namespace: str = "agent-sandboxes" + # Default warm pool to claim from when a request doesn't name one. + default_warmpool: str = "python-sandbox-warmpool" + # How long to wait for a claimed sandbox to report Ready. + sandbox_ready_timeout: int = 180 + # Server port the sandbox runtime container listens on (SDK default 8888). + sandbox_server_port: int = 8888 + # Gateway-mode discovery (only used when connection_mode == "gateway"). + gateway_name: str = "external-http-gateway" + gateway_namespace: str = "default" + # Direct-mode router URL (only used when connection_mode == "direct"). + router_api_url: str = "" + # Local-tunnel router namespace (only used when connection_mode == "local-tunnel"). + router_namespace: str = "agent-sandbox-system" + + # --- Lifecycle / safety nets ----------------------------------------- + # TTL handed to the controller via SandboxClaim.spec.lifecycle.shutdownTime. + # The controller auto-deletes the claim on expiry even if we crash mid-run. + sandbox_ttl_seconds: int = 3600 + # Per-command execution timeout inside the sandbox. + command_timeout: int = 300 + # Reaper sweep interval — belt-and-suspenders cleanup of expired records. + reaper_interval_seconds: int = 30 + + # --- Concurrency governance (carried over from session-router) -------- + max_concurrent_sandboxes: int = 100 + max_concurrent_per_dev: int = 3 + + # --- Agent command derivation ---------------------------------------- + # When a request supplies a ``task`` but no explicit ``command``, the + # orchestrator derives the command from this template. ``{task}`` is + # shell-quoted before substitution. The default runs Claude Code headless + # inside the sandbox — the same agent Code Forge fronts everywhere else. + agent_command_template: str = "claude -p {task}" + + # Retention window for finished request records before the reaper evicts + # them from the in-memory store (production would persist to Redis/Cosmos). + record_retention_seconds: int = 3600 + + extra: dict = field(default_factory=dict) + + @classmethod + def from_env(cls) -> "Config": + backend = os.getenv("ORCHESTRATOR_BACKEND", "sdk").strip().lower() + if backend not in VALID_BACKENDS: + raise ValueError( + f"ORCHESTRATOR_BACKEND must be one of {sorted(VALID_BACKENDS)}, " + f"got {backend!r}" + ) + + connection_mode = os.getenv("SANDBOX_CONNECTION_MODE", "in-cluster").strip().lower() + if connection_mode not in VALID_CONNECTION_MODES: + raise ValueError( + f"SANDBOX_CONNECTION_MODE must be one of " + f"{sorted(VALID_CONNECTION_MODES)}, got {connection_mode!r}" + ) + + cfg = cls( + host=os.getenv("ORCHESTRATOR_HOST", "0.0.0.0"), + port=_env_int("ORCHESTRATOR_PORT", 8080), + backend=backend, + connection_mode=connection_mode, + sandbox_namespace=os.getenv("SANDBOX_NAMESPACE", "agent-sandboxes"), + default_warmpool=os.getenv("SANDBOX_WARMPOOL", "python-sandbox-warmpool"), + sandbox_ready_timeout=_env_int("SANDBOX_READY_TIMEOUT", 180), + sandbox_server_port=_env_int("SANDBOX_SERVER_PORT", 8888), + gateway_name=os.getenv("SANDBOX_GATEWAY_NAME", "external-http-gateway"), + gateway_namespace=os.getenv("SANDBOX_GATEWAY_NAMESPACE", "default"), + router_api_url=os.getenv("SANDBOX_ROUTER_API_URL", ""), + router_namespace=os.getenv("SANDBOX_ROUTER_NAMESPACE", "agent-sandbox-system"), + sandbox_ttl_seconds=_env_int("SANDBOX_TTL_SECONDS", 3600), + command_timeout=_env_int("SANDBOX_COMMAND_TIMEOUT", 300), + reaper_interval_seconds=_env_int("REAPER_INTERVAL_SECONDS", 30), + max_concurrent_sandboxes=_env_int("MAX_CONCURRENT_SANDBOXES", 100), + max_concurrent_per_dev=_env_int("MAX_CONCURRENT_PER_DEV", 3), + agent_command_template=os.getenv("AGENT_COMMAND_TEMPLATE", "claude -p {task}"), + record_retention_seconds=_env_int("RECORD_RETENTION_SECONDS", 3600), + ) + cfg.validate() + return cfg + + def validate(self) -> None: + if self.port <= 0 or self.port > 65535: + raise ValueError(f"port out of range: {self.port}") + if self.sandbox_ready_timeout <= 0: + raise ValueError("sandbox_ready_timeout must be positive") + if self.sandbox_ttl_seconds <= 0: + raise ValueError("sandbox_ttl_seconds must be positive") + if self.command_timeout <= 0: + raise ValueError("command_timeout must be positive") + if self.max_concurrent_sandboxes <= 0: + raise ValueError("max_concurrent_sandboxes must be positive") + if self.max_concurrent_per_dev <= 0: + raise ValueError("max_concurrent_per_dev must be positive") + if "{task}" not in self.agent_command_template: + raise ValueError("agent_command_template must contain '{task}'") + if self.connection_mode == "direct" and not self.router_api_url: + raise ValueError("SANDBOX_ROUTER_API_URL is required when connection_mode=direct") diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py b/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py new file mode 100644 index 0000000..56f8005 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py @@ -0,0 +1,268 @@ +"""Lifecycle manager — the orchestration core. + +The manager owns the request → sandbox → result → teardown lifecycle. It is the +sandbox-native replacement for the session-router's warm-pod claim/patch/reaper +loop. Responsibilities: + +* **Admission control** — enforce global and per-dev concurrency caps before + provisioning anything (carried over from the router's ``MAX_CONCURRENT_*``). +* **Lifecycle** — drive each request through the :class:`RequestState` machine, + always terminating the sandbox in a ``finally`` so a crash mid-run cannot + leak compute. The controller-side TTL is the second safety net. +* **Bookkeeping** — keep an in-memory record store (the reference impl; prod + swaps in Redis + Cosmos) and a background reaper that evicts stale records. + +Thread-safety: the manager is synchronous and guarded by a single re-entrant +lock. The FastAPI layer runs ``handle_request`` in a worker thread +(``run_in_threadpool``) so the blocking SDK calls don't stall the event loop, +and concurrent requests serialise only around the short critical sections +(admission + record mutation), not around the long sandbox run. +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Iterable + +from .backends import SandboxBackend, SandboxHandle, derive_command +from .config import Config +from .models import ( + ExecResult, + RequestState, + SandboxRecord, + SandboxRequest, + new_request_id, +) + +logger = logging.getLogger("sandbox_orchestrator.manager") + + +class CapacityError(RuntimeError): + """Raised when an admission cap would be exceeded. Maps to HTTP 429.""" + + +class NotFoundError(KeyError): + """Raised when a request_id is unknown. Maps to HTTP 404.""" + + +class SandboxManager: + """Coordinates request admission, sandbox lifecycle, and record-keeping.""" + + def __init__(self, config: Config, backend: SandboxBackend): + self._config = config + self._backend = backend + self._records: dict[str, SandboxRecord] = {} + self._active_by_dev: dict[str, int] = {} + self._active_total = 0 + self._lock = threading.RLock() + self._reaper_stop = threading.Event() + self._reaper_thread: threading.Thread | None = None + + # -- lifecycle of the manager itself ---------------------------------- + + def start_reaper(self) -> None: + if self._reaper_thread is not None: + return + self._reaper_stop.clear() + self._reaper_thread = threading.Thread( + target=self._reaper_loop, name="record-reaper", daemon=True + ) + self._reaper_thread.start() + logger.info("record reaper started (interval=%ss)", self._config.reaper_interval_seconds) + + def stop_reaper(self) -> None: + self._reaper_stop.set() + if self._reaper_thread is not None: + self._reaper_thread.join(timeout=5) + self._reaper_thread = None + + # -- admission control ------------------------------------------------- + + def _admit(self, dev_id: str) -> None: + """Reserve a concurrency slot or raise CapacityError. Caller holds lock.""" + if self._active_total >= self._config.max_concurrent_sandboxes: + raise CapacityError( + f"global concurrency cap reached " + f"({self._config.max_concurrent_sandboxes})" + ) + dev_active = self._active_by_dev.get(dev_id, 0) + if dev_active >= self._config.max_concurrent_per_dev: + raise CapacityError( + f"per-dev concurrency cap reached for {dev_id!r} " + f"({self._config.max_concurrent_per_dev})" + ) + self._active_by_dev[dev_id] = dev_active + 1 + self._active_total += 1 + + def _release(self, dev_id: str) -> None: + """Release a previously reserved slot. Caller holds lock.""" + self._active_total = max(0, self._active_total - 1) + remaining = self._active_by_dev.get(dev_id, 0) - 1 + if remaining <= 0: + self._active_by_dev.pop(dev_id, None) + else: + self._active_by_dev[dev_id] = remaining + + # -- the main entrypoint ---------------------------------------------- + + def handle_request(self, req: SandboxRequest) -> SandboxRecord: + """Provision a sandbox, run the work, tear down, and return the record. + + Blocking and synchronous: intended to be called from a threadpool + worker. Admission is checked up-front; the slot is held for the entire + run and always released in ``finally``. + """ + req.validate() + warmpool = req.warmpool or self._config.default_warmpool + record = SandboxRecord( + request_id=new_request_id(), + dev_id=req.dev_id, + project_id=req.project_id, + state=RequestState.PENDING, + warmpool=warmpool, + labels=dict(req.labels), + ) + + with self._lock: + self._admit(req.dev_id) + self._records[record.request_id] = record + + handle: SandboxHandle | None = None + try: + # --- provision ------------------------------------------------ + self._set_state(record, RequestState.PROVISIONING) + labels = self._sandbox_labels(req, record) + handle = self._backend.create_sandbox( + warmpool=warmpool, + labels=labels, + ttl_seconds=req.ttl_seconds if req.ttl_seconds is not None + else self._config.sandbox_ttl_seconds, + ) + with self._lock: + record.sandbox_id = handle.sandbox_id + record.claim_name = handle.claim_name + record.touch() + + # --- stage input files --------------------------------------- + for path, content in req.files.items(): + self._backend.write_file(handle, path, content) + + # --- run ------------------------------------------------------ + command = derive_command(self._config, task=req.task, command=req.command) + self._set_state(record, RequestState.RUNNING) + result = self._backend.run(handle, command, timeout=self._config.command_timeout) + + with self._lock: + record.result = result + record.touch( + RequestState.SUCCEEDED if result.exit_code == 0 else RequestState.FAILED + ) + if result.exit_code != 0: + logger.warning( + "request %s command exited %s", record.request_id, result.exit_code + ) + return record + + except CapacityError: + raise + except Exception as exc: # noqa: BLE001 - record then surface + logger.exception("request %s failed", record.request_id) + with self._lock: + record.error = str(exc) + if record.result is None: + record.result = ExecResult(exit_code=-1, stderr=str(exc)) + record.touch(RequestState.FAILED) + return record + finally: + # Always tear the sandbox down and free the admission slot. + if handle is not None: + try: + self._backend.terminate(handle) + except Exception: # noqa: BLE001 + logger.exception( + "failed to terminate sandbox %s (TTL will reap it)", + getattr(handle, "sandbox_id", "?"), + ) + with self._lock: + self._release(req.dev_id) + + def cancel(self, request_id: str) -> SandboxRecord: + """Mark a request TERMINATED. Best-effort; the run's ``finally`` still + owns sandbox teardown, so this is primarily a bookkeeping signal.""" + with self._lock: + record = self._records.get(request_id) + if record is None: + raise NotFoundError(request_id) + if not record.state.is_terminal: + record.touch(RequestState.TERMINATED) + return record + + # -- queries ----------------------------------------------------------- + + def get(self, request_id: str) -> SandboxRecord: + with self._lock: + record = self._records.get(request_id) + if record is None: + raise NotFoundError(request_id) + return record + + def list_records(self, *, dev_id: str | None = None) -> list[SandboxRecord]: + with self._lock: + records: Iterable[SandboxRecord] = list(self._records.values()) + if dev_id is not None: + records = [r for r in records if r.dev_id == dev_id] + return sorted(records, key=lambda r: r.created_at, reverse=True) + + def stats(self) -> dict: + with self._lock: + return { + "active_total": self._active_total, + "active_by_dev": dict(self._active_by_dev), + "records": len(self._records), + "max_concurrent_sandboxes": self._config.max_concurrent_sandboxes, + "max_concurrent_per_dev": self._config.max_concurrent_per_dev, + } + + # -- internals --------------------------------------------------------- + + def _set_state(self, record: SandboxRecord, state: RequestState) -> None: + with self._lock: + record.touch(state) + + def _sandbox_labels(self, req: SandboxRequest, record: SandboxRecord) -> dict[str, str]: + labels = { + "code-forge.io/request-id": record.request_id, + "code-forge.io/dev-id": _sanitize_label(req.dev_id), + "code-forge.io/managed-by": "sandbox-orchestrator", + } + if req.project_id: + labels["code-forge.io/project-id"] = _sanitize_label(req.project_id) + labels.update(req.labels) + return labels + + def _reaper_loop(self) -> None: + while not self._reaper_stop.wait(self._config.reaper_interval_seconds): + try: + self._evict_stale() + except Exception: # noqa: BLE001 + logger.exception("reaper sweep failed") + + def _evict_stale(self) -> None: + cutoff = time.time() - self._config.record_retention_seconds + with self._lock: + stale = [ + rid for rid, rec in self._records.items() + if rec.state.is_terminal and rec.updated_at < cutoff + ] + for rid in stale: + del self._records[rid] + if stale: + logger.info("reaper evicted %d stale record(s)", len(stale)) + + +def _sanitize_label(value: str) -> str: + """Coerce an arbitrary id into a DNS-1123-ish label value (<=63 chars).""" + safe = "".join(c if c.isalnum() or c in "-_." else "-" for c in value) + return safe[:63].strip("-_.") or "unknown" diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/models.py b/containers/sandbox-orchestrator/sandbox_orchestrator/models.py new file mode 100644 index 0000000..f15d696 --- /dev/null +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/models.py @@ -0,0 +1,152 @@ +"""Domain models for the sandbox orchestrator. + +Plain dataclasses (no pydantic dependency) so the core is import-light and the +fake backend / unit tests run with zero third-party installs. The HTTP layer +serialises these to/from JSON by hand in ``api.py``. +""" + +from __future__ import annotations + +import enum +import time +import uuid +from dataclasses import dataclass, field, asdict +from typing import Any + + +class RequestState(str, enum.Enum): + """Lifecycle of a single user request → sandbox run. + + :: + + PENDING ──▶ PROVISIONING ──▶ RUNNING ──▶ SUCCEEDED + │ │ │ + └──────────────┴────────────┴──▶ FAILED + └──▶ TERMINATED + """ + + PENDING = "pending" # accepted, not yet acted on + PROVISIONING = "provisioning" # claiming + waiting for sandbox Ready + RUNNING = "running" # command executing inside the sandbox + SUCCEEDED = "succeeded" # command finished, exit_code == 0 + FAILED = "failed" # provisioning error or non-zero exit + TERMINATED = "terminated" # explicitly cancelled / sandbox torn down + + @property + def is_terminal(self) -> bool: + return self in {RequestState.SUCCEEDED, RequestState.FAILED, RequestState.TERMINATED} + + +@dataclass +class SandboxRequest: + """An inbound user request for a one-shot agent sandbox run. + + Exactly one of ``task`` or ``command`` should be set by the caller: + + * ``task`` — natural-language work; the orchestrator derives the agent + command from ``Config.agent_command_template``. + * ``command`` — an explicit shell command to run verbatim in the sandbox. + """ + + dev_id: str + project_id: str = "" + task: str = "" + command: str = "" + warmpool: str = "" # overrides Config.default_warmpool when set + ttl_seconds: int | None = None # overrides Config.sandbox_ttl_seconds when set + files: dict[str, str] = field(default_factory=dict) # path → content, written pre-run + labels: dict[str, str] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def validate(self) -> None: + if not self.dev_id or not self.dev_id.strip(): + raise ValueError("dev_id is required") + if not (self.task.strip() or self.command.strip()): + raise ValueError("one of 'task' or 'command' is required") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "SandboxRequest": + if not isinstance(data, dict): + raise ValueError("request body must be a JSON object") + allowed = { + "dev_id", "project_id", "task", "command", + "warmpool", "ttl_seconds", "files", "labels", "metadata", + } + unknown = set(data) - allowed + if unknown: + raise ValueError(f"unknown fields: {sorted(unknown)}") + req = cls( + dev_id=str(data.get("dev_id", "")), + project_id=str(data.get("project_id", "")), + task=str(data.get("task", "")), + command=str(data.get("command", "")), + warmpool=str(data.get("warmpool", "")), + ttl_seconds=data.get("ttl_seconds"), + files=dict(data.get("files", {}) or {}), + labels=dict(data.get("labels", {}) or {}), + metadata=dict(data.get("metadata", {}) or {}), + ) + req.validate() + return req + + +@dataclass +class ExecResult: + """Result of running the agent command inside the sandbox.""" + + stdout: str = "" + stderr: str = "" + exit_code: int = -1 + + +@dataclass +class SandboxRecord: + """Server-side state for one request through its full lifecycle. + + In production this would be persisted to Redis (hot) + Cosmos (audit), + mirroring the session-router. The in-memory store is the reference + implementation and what the tests drive. + """ + + request_id: str + dev_id: str + project_id: str + state: RequestState + warmpool: str + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + # Populated once the backend hands us a sandbox handle. + sandbox_id: str = "" + claim_name: str = "" + # Populated once the command has run. + result: ExecResult | None = None + error: str = "" + labels: dict[str, str] = field(default_factory=dict) + + def touch(self, state: RequestState | None = None) -> None: + if state is not None: + self.state = state + self.updated_at = time.time() + + def to_public_dict(self) -> dict[str, Any]: + """JSON-safe view returned to API callers.""" + out: dict[str, Any] = { + "request_id": self.request_id, + "dev_id": self.dev_id, + "project_id": self.project_id, + "state": self.state.value, + "warmpool": self.warmpool, + "sandbox_id": self.sandbox_id, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + if self.result is not None: + out["result"] = asdict(self.result) + if self.error: + out["error"] = self.error + return out + + +def new_request_id() -> str: + """Short, collision-resistant id used in URLs and sandbox labels.""" + return f"req-{uuid.uuid4().hex[:12]}" diff --git a/containers/sandbox-orchestrator/tests/test_orchestrator.py b/containers/sandbox-orchestrator/tests/test_orchestrator.py new file mode 100644 index 0000000..abc188e --- /dev/null +++ b/containers/sandbox-orchestrator/tests/test_orchestrator.py @@ -0,0 +1,299 @@ +"""End-to-end tests for the sandbox orchestrator. + +These drive the *full* request lifecycle through the FastAPI app against the +in-memory :class:`FakeSandboxBackend` — no Kubernetes, no SDK, no network. They +prove admission control, the state machine, sandbox teardown, and the HTTP +contract all hold together. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from sandbox_orchestrator.api import create_app +from sandbox_orchestrator.backends import FakeSandboxBackend, derive_command +from sandbox_orchestrator.config import Config +from sandbox_orchestrator.manager import SandboxManager +from sandbox_orchestrator.models import ExecResult, RequestState, SandboxRequest + + +def make_app(backend=None, **cfg_overrides): + config = Config(backend="fake", connection_mode="in-cluster", **cfg_overrides) + backend = backend or FakeSandboxBackend() + manager = SandboxManager(config, backend) + app = create_app(config=config, manager=manager) + return app, manager, backend + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def test_config_rejects_bad_template(): + with pytest.raises(ValueError): + Config(agent_command_template="no placeholder here").validate() + + +def test_config_direct_mode_requires_url(): + with pytest.raises(ValueError): + Config(connection_mode="direct", router_api_url="").validate() + + +def test_config_from_env_roundtrip(monkeypatch): + monkeypatch.setenv("ORCHESTRATOR_BACKEND", "fake") + monkeypatch.setenv("SANDBOX_WARMPOOL", "my-pool") + monkeypatch.setenv("MAX_CONCURRENT_PER_DEV", "7") + cfg = Config.from_env() + assert cfg.backend == "fake" + assert cfg.default_warmpool == "my-pool" + assert cfg.max_concurrent_per_dev == 7 + + +# --------------------------------------------------------------------------- +# Command derivation +# --------------------------------------------------------------------------- + + +def test_derive_command_prefers_explicit(): + cfg = Config() + assert derive_command(cfg, task="ignored", command="ls -la") == "ls -la" + + +def test_derive_command_quotes_task(): + cfg = Config(agent_command_template="claude -p {task}") + cmd = derive_command(cfg, task="build a thing; rm -rf /", command="") + # The malicious-looking task must be a single shell-quoted token. + assert "claude -p " in cmd + assert "'build a thing; rm -rf /'" in cmd + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +def test_request_validation(): + with pytest.raises(ValueError): + SandboxRequest(dev_id="").validate() + with pytest.raises(ValueError): + SandboxRequest(dev_id="alice").validate() # no task or command + + +def test_request_from_dict_rejects_unknown_fields(): + with pytest.raises(ValueError): + SandboxRequest.from_dict({"dev_id": "a", "task": "x", "bogus": 1}) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_successful_run_full_lifecycle(): + app, manager, backend = make_app() + client = TestClient(app) + + resp = client.post("/v1/sandboxes", json={"dev_id": "alice", "task": "do work"}) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["state"] == RequestState.SUCCEEDED.value + assert body["result"]["exit_code"] == 0 + assert body["sandbox_id"].startswith("sbx-") + + # Sandbox was created AND torn down. + assert len(backend.created) == 1 + assert backend.terminated_ids == [body["sandbox_id"]] + + # Slot was released. + assert manager.stats()["active_total"] == 0 + + +def test_files_are_staged_before_run(): + captured = {} + + def runner(command, files): + captured.update(files) + return ExecResult(stdout="ok", exit_code=0) + + backend = FakeSandboxBackend(runner=runner) + app, _, _ = make_app(backend=backend) + client = TestClient(app) + + resp = client.post("/v1/sandboxes", json={ + "dev_id": "bob", + "command": "run tests", + "files": {"main.py": "print(1)", "spec.md": "# spec"}, + }) + assert resp.status_code == 201 + assert captured == {"main.py": "print(1)", "spec.md": "# spec"} + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + + +def test_nonzero_exit_marks_failed(): + backend = FakeSandboxBackend( + runner=lambda cmd, files: ExecResult(stdout="", stderr="boom", exit_code=2) + ) + app, _, backend = make_app(backend=backend) + client = TestClient(app) + + resp = client.post("/v1/sandboxes", json={"dev_id": "carol", "command": "false"}) + assert resp.status_code == 200 + body = resp.json() + assert body["state"] == RequestState.FAILED.value + assert body["result"]["exit_code"] == 2 + # Even on failure the sandbox is reaped. + assert len(backend.terminated_ids) == 1 + + +def test_provisioning_failure_is_handled_and_cleaned_up(): + backend = FakeSandboxBackend(fail_warmpools={"broken-pool"}) + app, manager, backend = make_app(backend=backend) + client = TestClient(app) + + resp = client.post("/v1/sandboxes", json={ + "dev_id": "dave", "task": "x", "warmpool": "broken-pool", + }) + assert resp.status_code == 200 + body = resp.json() + assert body["state"] == RequestState.FAILED.value + assert "broken-pool" in body["error"] + # No sandbox came up, nothing to terminate, slot released. + assert backend.terminated_ids == [] + assert manager.stats()["active_total"] == 0 + + +def test_terminate_failure_does_not_break_request(): + class FlakyTerminate(FakeSandboxBackend): + def terminate(self, handle): + raise RuntimeError("k8s api flake") + + app, manager, _ = make_app(backend=FlakyTerminate()) + client = TestClient(app) + resp = client.post("/v1/sandboxes", json={"dev_id": "erin", "command": "ok"}) + # Run still succeeds; teardown error is swallowed (TTL reaps the sandbox). + assert resp.status_code == 201 + assert resp.json()["state"] == RequestState.SUCCEEDED.value + assert manager.stats()["active_total"] == 0 + + +# --------------------------------------------------------------------------- +# Admission control +# --------------------------------------------------------------------------- + + +def test_per_dev_concurrency_cap(): + # Block inside run() so the slot stays held while we fire a second request. + import threading + gate = threading.Event() + + def runner(command, files): + gate.wait(timeout=5) + return ExecResult(exit_code=0) + + backend = FakeSandboxBackend(runner=runner) + app, manager, _ = make_app(backend=backend, max_concurrent_per_dev=1) + client = TestClient(app) + + results = {} + + def fire(key): + results[key] = client.post("/v1/sandboxes", json={"dev_id": "sam", "command": "x"}) + + t1 = threading.Thread(target=fire, args=("first",)) + t1.start() + # Wait until the first request has actually grabbed the slot. + for _ in range(500): + if manager.stats()["active_total"] == 1: + break + import time as _t; _t.sleep(0.005) + + second = client.post("/v1/sandboxes", json={"dev_id": "sam", "command": "y"}) + assert second.status_code == 429 + assert "per-dev" in second.json()["error"] + + gate.set() + t1.join(timeout=5) + assert results["first"].status_code == 201 + + +def test_global_concurrency_cap_isolated_from_per_dev(): + import threading + gate = threading.Event() + + def runner(command, files): + gate.wait(timeout=5) + return ExecResult(exit_code=0) + + backend = FakeSandboxBackend(runner=runner) + app, manager, _ = make_app( + backend=backend, max_concurrent_sandboxes=1, max_concurrent_per_dev=5 + ) + client = TestClient(app) + + def fire(): + client.post("/v1/sandboxes", json={"dev_id": "u1", "command": "x"}) + + t = threading.Thread(target=fire); t.start() + for _ in range(500): + if manager.stats()["active_total"] == 1: + break + import time as _t; _t.sleep(0.005) + + # Different dev, but global cap of 1 is saturated. + resp = client.post("/v1/sandboxes", json={"dev_id": "u2", "command": "y"}) + assert resp.status_code == 429 + assert "global" in resp.json()["error"] + gate.set(); t.join(timeout=5) + + +# --------------------------------------------------------------------------- +# Query + cancel surface +# --------------------------------------------------------------------------- + + +def test_get_list_and_cancel(): + app, _, _ = make_app() + client = TestClient(app) + + created = client.post("/v1/sandboxes", json={"dev_id": "z", "command": "x"}).json() + rid = created["request_id"] + + got = client.get(f"/v1/sandboxes/{rid}") + assert got.status_code == 200 + assert got.json()["request_id"] == rid + + listed = client.get("/v1/sandboxes", params={"dev_id": "z"}) + assert listed.status_code == 200 + assert listed.json()["count"] == 1 + + # Already-terminal records aren't moved to TERMINATED by cancel. + cancelled = client.delete(f"/v1/sandboxes/{rid}") + assert cancelled.status_code == 200 + assert cancelled.json()["state"] == RequestState.SUCCEEDED.value + + assert client.get("/v1/sandboxes/req-doesnotexist").status_code == 404 + assert client.delete("/v1/sandboxes/req-doesnotexist").status_code == 404 + + +def test_healthz_and_stats(): + app, _, _ = make_app() + client = TestClient(app) + assert client.get("/healthz").json() == {"status": "ok"} + stats = client.get("/stats").json() + assert stats["active_total"] == 0 + assert stats["max_concurrent_per_dev"] >= 1 + + +def test_invalid_body_returns_400(): + app, _, _ = make_app() + client = TestClient(app) + assert client.post("/v1/sandboxes", json={"task": "no dev id"}).status_code == 400 + bad = client.post("/v1/sandboxes", content=b"not json", + headers={"content-type": "application/json"}) + assert bad.status_code == 400 From 29f5e83f8a221037e2bb01bd0a04ccbb9d763a8d Mon Sep 17 00:00:00 2001 From: Michael Liav Date: Tue, 9 Jun 2026 11:32:48 +0300 Subject: [PATCH 4/8] Fully migrate to sandbox orchestrator; remove legacy session-router stack - Wire the orchestrator to claim pre-warmed pods from the SandboxWarmPool (warm-pool-aware DirectSandboxBackend + claim-aware teardown) - Make the SandboxTemplate a full agent-pod (Foundry gateway env, writable workspace/tmp/cache under read-only root, fsGroup, egress NetworkPolicy) - Add sandbox_template + sandbox_use_warmpool config knobs - Remove legacy agent-pod Deployment/KEDA and session-router (chart templates, values, deploy/ loose YAML, containers/session-router, Makefile targets) - Update Chart.yaml, root + chart CLAUDE.md, chart README to orchestrator model --- .gitignore | 2 +- CLAUDE.md | 49 ++- Makefile | 14 +- charts/code-forge/CLAUDE.md | 57 +-- charts/code-forge/Chart.yaml | 5 +- charts/code-forge/README.md | 62 ++-- .../code-forge/templates/00-namespaces.yaml | 2 +- .../templates/05-serviceaccounts.yaml | 4 +- charts/code-forge/templates/20-agent-pod.yaml | 134 ------- .../templates/30-session-router.yaml | 134 ------- .../templates/35-sandbox-orchestrator.yaml | 33 +- .../templates/36-sandbox-template.yaml | 103 ++++++ .../templates/50-network-policies.yaml | 66 ++-- charts/code-forge/values-prod.yaml | 52 +-- charts/code-forge/values.yaml | 189 ++++------ containers/agent-pod/Dockerfile | 4 +- containers/model-gateway/Dockerfile | 3 +- .../sandbox-orchestrator/requirements.txt | 4 + .../sandbox_orchestrator/backends.py | 335 +++++++++++++++++- .../sandbox_orchestrator/config.py | 43 ++- .../sandbox_orchestrator/manager.py | 1 + containers/session-router/CLAUDE.md | 69 ---- containers/session-router/Dockerfile | 15 - containers/session-router/go.mod | 51 --- containers/session-router/go.sum | 162 --------- containers/session-router/main.go | 155 -------- deploy/agent-pod-pool.yaml | 118 ------ deploy/router/main.go | 155 -------- deploy/session-router.yaml | 92 ----- 29 files changed, 718 insertions(+), 1395 deletions(-) delete mode 100644 charts/code-forge/templates/20-agent-pod.yaml delete mode 100644 charts/code-forge/templates/30-session-router.yaml create mode 100644 charts/code-forge/templates/36-sandbox-template.yaml delete mode 100644 containers/session-router/CLAUDE.md delete mode 100644 containers/session-router/Dockerfile delete mode 100644 containers/session-router/go.mod delete mode 100644 containers/session-router/go.sum delete mode 100644 containers/session-router/main.go delete mode 100644 deploy/agent-pod-pool.yaml delete mode 100644 deploy/router/main.go delete mode 100644 deploy/session-router.yaml diff --git a/.gitignore b/.gitignore index daf487e..dfa88f0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ build/ *.egg-info/ .idea/ .vscode/ -runs/ +runs/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4b5902f..fa9b929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,12 +21,12 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. ## The three primitives ``` -┌───────────────────────────────────────────────────────────────────┐ -│ developer ──HTTP──▶ session-router ──k8s patch──▶ agent pod │ +┌────────────────────────────────────────────────────────┐ +│ developer ──HTTP──▶ sandbox-orchestrator ──claim──▶ agent pod │ │ (laptop / IDE) │ │ │ │ ▼ ▼ │ -│ Redis + Cosmos Claude Code │ -│ (session state) │ │ +│ SandboxWarmPool (CRD) Claude Code │ +│ (pre-warmed sandboxes) │ │ │ ▼ │ │ model-gateway │ │ (LiteLLM) │ @@ -39,8 +39,8 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. | Primitive | What it is | Where it lives | Why | |---|---|---|---| -| **Agent pod** | Ubuntu devcontainer w/ Claude Code installed via the official `ghcr.io/anthropics/devcontainer-features/claude-code:1.0` Feature | `containers/agent-pod/` + `charts/code-forge/templates/20-agent-pod.yaml` | Stateless, ephemeral, label-driven state machine: `warm → bound → cooldown` | -| **Session router** | Go service that maps `(dev_id, project_id) → free pod`, mints virtual keys, patches pod labels, scrubs idle | `containers/session-router/` + `charts/code-forge/templates/30-session-router.yaml` | The traffic cop. Stateless; state in Redis + Cosmos. Scales via KEDA HTTP Add-on (RPS) | +| **Agent sandbox** | Ubuntu devcontainer w/ Claude Code installed via the official `ghcr.io/anthropics/devcontainer-features/claude-code:1.0` Feature | `containers/agent-pod/` (image) + `charts/code-forge/templates/36-sandbox-template.yaml` (`SandboxTemplate`/`SandboxWarmPool`) | Stateless, ephemeral, pre-warmed in a pool; writable `/workspace` under a read-only root filesystem | +| **Sandbox orchestrator** | Python (FastAPI) service that provisions ONE agent sandbox per request — claims a warm pod, runs the command over the apiserver exec stream, tears it down | `containers/sandbox-orchestrator/` + `charts/code-forge/templates/35-sandbox-orchestrator.yaml` | The traffic cop. Stateless; relies on the agent-sandbox CRDs (`SandboxClaim`/`SandboxTemplate`/`SandboxWarmPool`) | | **Model gateway** | LiteLLM proxy that fronts Foundry, holds an AAD token (refreshed via Workload Identity), enforces per-dev budgets | `containers/model-gateway/` + `charts/code-forge/templates/40-model-gateway.yaml` | Single audit/billing chokepoint. Agents never see Foundry directly | ## Repo map (start here) @@ -60,8 +60,8 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. │ └── templates/ │ ├── 00-namespaces.yaml │ ├── 05-serviceaccounts.yaml -│ ├── 20-agent-pod.yaml -│ ├── 30-session-router.yaml +│ ├── 35-sandbox-orchestrator.yaml +│ ├── 36-sandbox-template.yaml │ ├── 40-model-gateway.yaml │ └── 50-network-policies.yaml │ @@ -73,10 +73,11 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. │ │ ├── agent-shutdown │ │ ├── healthz.py │ │ └── CLAUDE.md -│ ├── session-router/ ← Go (client-go + Redis) -│ │ ├── main.go +│ ├── sandbox-orchestrator/ ← Python (FastAPI + agent-sandbox SDK) +│ │ ├── sandbox_orchestrator/ ← api, manager, backends, config, models +│ │ ├── tests/ │ │ ├── Dockerfile -│ │ ├── go.mod +│ │ ├── pyproject.toml │ │ └── CLAUDE.md │ └── model-gateway/ ← LiteLLM + AAD-token sidecar │ ├── Dockerfile @@ -90,8 +91,6 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. │ │ … servicebus, acr, aks, frontdoor, foundry │ └── CLAUDE.md │ -├── deploy/ ← reference loose YAML (pre-Helm) -│ └── docs/ ├── ARCHITECTURE.md ← deep-dive on every primitive ├── ONBOARDING.md ← 30-minute new-engineer ramp @@ -104,21 +103,20 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. ## How a request flows (read this once, it makes everything click) 1. Developer runs `claude` in their IDE (or their CLI hits `https://api.codeforge.example.com`). -2. A thin client call goes to **session-router** with `{dev_id, project_id}`. -3. Router looks up Redis: existing warm session? → return pod handle. - No session? → list pods labeled `app=agent-pod,state=warm`, pick one, **patch labels** to `state=bound, dev-id=…, session-id=…`, attach the dev's workspace PVC, mint a per-session virtual key in LiteLLM, write session record to Cosmos. -4. Router returns the pod handle. Client streams I/O via `kubectl exec` (or an HTTP shim) into that pod. -5. Inside the pod, Claude Code calls the **model-gateway** (env: `ANTHROPIC_FOUNDRY_BASE_URL=http://model-gateway.platform.svc.cluster.local/anthropic`). +2. A thin client call goes to the **sandbox-orchestrator** with `{dev_id, command}`. +3. The orchestrator creates a `SandboxClaim` that **adopts a pre-warmed pod** from the `SandboxWarmPool` (sub-2s), instead of cold-starting. No free warm pod? → the claim provisions a fresh sandbox from the `SandboxTemplate`. +4. The orchestrator streams I/O into the sandbox via the kube-apiserver `exec` stream (the agent-pod image runs Claude Code, not an in-pod HTTP server). +5. Inside the sandbox, Claude Code calls the **model-gateway** (env: `ANTHROPIC_FOUNDRY_BASE_URL=http://model-gateway.platform.svc.cluster.local/anthropic`). 6. The gateway authenticates to **Foundry** with a federated AAD token, applies budget/RPM caps, forwards the request, logs cost. -7. Idle for 15 min → router patches pod `state=cooldown`, the pod's preStop hook scrubs `/workspace`, the ReplicaSet brings up a fresh `state=warm` replacement. +7. On completion (or TTL), the orchestrator deletes the `SandboxClaim`; the warm pool self-heals back to its target `readyReplicas`. ## Conventions - **No static API keys.** Every credential is Azure Workload Identity (federated OIDC). If you're tempted to add `ANTHROPIC_API_KEY` to a secret, stop and read `docs/SECURITY.md`. - **Pin model versions explicitly.** Aliases (`opus`, `sonnet`, `haiku`) auto-resolve on Foundry and break when Anthropic releases new models. We pin `claude-opus-4-8`, `claude-sonnet-4-6`, `claude-haiku-4-5` in `values.yaml`. -- **Pods are cattle.** Agent pods MUST be safe to nuke at any time. Anything durable goes in Redis (sessions), Cosmos (audit), or PVCs (per-dev workspaces). -- **Default-deny networking.** Agent pods can only reach the model-gateway and DNS — no public egress, no direct Foundry calls, no internal lateral movement. -- **Helm is the source of truth** for what's running. The loose YAML under `deploy/` is reference material; production deploys go through `make chart-install`. +- **Sandboxes are cattle.** Agent sandboxes MUST be safe to nuke at any time. Anything durable goes in the model-gateway audit log or per-dev PVCs. +- **Default-deny networking.** Agent sandboxes can only reach the model-gateway and DNS — no public egress, no direct Foundry calls, no internal lateral movement. +- **Helm is the source of truth** for what's running. Production deploys go through `make chart-install`. - **Bicep is the source of truth** for what Azure resources exist. ## Common tasks @@ -127,7 +125,7 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. |---|---| | Change agent-pod image | `containers/agent-pod/Dockerfile` + bump `agentPod.image.tag` | | Add a model | `values.yaml` → `global.foundry.models` + LiteLLM ConfigMap | -| Tweak warm-pool size | `values.yaml` → `agentPod.replicas` (baseline) and `agentPod.keda.minReplicas/maxReplicas` | +| Tweak warm-pool size | `values.yaml` → `sandboxOrchestrator.sandbox.warmpoolReplicas` | | Add a per-dev budget | `values.yaml` → `modelGateway.budgets` | | Add a network egress allow | `templates/50-network-policies.yaml` | | Rotate LiteLLM master key | `docs/OPERATIONS.md` → "Key rotation" | @@ -140,8 +138,8 @@ rate-limits, audit, and a warm pool so cold-start is sub-2-seconds. make chart-lint make chart-template | head -50 -# Compile router -cd containers/session-router && go build ./... +# Orchestrator unit tests +cd containers/sandbox-orchestrator && python -m pytest -q # Dry-run image build (needs Docker daemon) docker build -t code-forge/agent-pod:dev containers/agent-pod @@ -152,7 +150,6 @@ docker build -t code-forge/agent-pod:dev containers/agent-pod - Claude Code dev container: - Claude Code on Foundry: - Claude Code LLM gateway: -- KEDA HTTP Add-on: - Azure Workload Identity: ## Who runs this diff --git a/Makefile b/Makefile index c372837..bdd9481 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,16 @@ # Code Forge top-level Makefile — image build/push + chart install/template. # ============================================================================= TAG ?= 1.0.0 -ACR ?= acrtheclouds.azurecr.io +ACR ?= codeforgedemo.azurecr.io RELEASE ?= code-forge NAMESPACE ?= session-control +# AKS nodes are linux/amd64; build for that platform even on Apple Silicon. +PLATFORM ?= linux/amd64 .PHONY: help help: @echo "Targets:" - @echo " build-images # docker build agent-pod + session-router + model-gateway" + @echo " build-images # docker build agent-pod + sandbox-orchestrator + model-gateway" @echo " push-images # docker push to \$$ACR" @echo " chart-lint # helm lint" @echo " chart-template # helm template (preview rendered yaml)" @@ -18,16 +20,16 @@ help: .PHONY: build-images build-images: - docker build -t $(ACR)/code-forge/agent-pod:$(TAG) containers/agent-pod - docker build -t $(ACR)/code-forge/session-router:$(TAG) containers/session-router - docker build -t $(ACR)/code-forge/model-gateway:$(TAG) containers/model-gateway + docker buildx build --platform $(PLATFORM) --load -t $(ACR)/code-forge/agent-pod:$(TAG) containers/agent-pod + docker buildx build --platform $(PLATFORM) --load -t $(ACR)/code-forge/model-gateway:$(TAG) containers/model-gateway + docker buildx build --platform $(PLATFORM) --load -t $(ACR)/code-forge/sandbox-orchestrator:$(TAG) containers/sandbox-orchestrator .PHONY: push-images push-images: az acr login -n $(firstword $(subst ., ,$(ACR))) docker push $(ACR)/code-forge/agent-pod:$(TAG) - docker push $(ACR)/code-forge/session-router:$(TAG) docker push $(ACR)/code-forge/model-gateway:$(TAG) + docker push $(ACR)/code-forge/sandbox-orchestrator:$(TAG) .PHONY: chart-lint chart-lint: diff --git a/charts/code-forge/CLAUDE.md b/charts/code-forge/CLAUDE.md index c8f884f..fa9d80b 100644 --- a/charts/code-forge/CLAUDE.md +++ b/charts/code-forge/CLAUDE.md @@ -5,16 +5,21 @@ ## What this chart deploys -Five logical sections, one template file each, numbered for render order: +Templates are numbered for render order: | File | Resources | Purpose | |---|---|---| -| `00-namespaces.yaml` | 3 × `Namespace` (PSA-restricted) | `session-control`, `agent-pool`, `platform`. Each carries `pod-security.kubernetes.io/enforce: restricted` so Pod Security Admission rejects privileged pods at admission time | -| `05-serviceaccounts.yaml` | 3 × `ServiceAccount` | Federated to Azure user-assigned managed identities via `azure.workload.identity/client-id` annotations. Token volume is auto-projected by the workload-identity webhook | -| `20-agent-pod.yaml` | `Deployment` + `ResourceQuota` + `ScaledObject` | Warm pool of agent containers. KEDA scales on Service Bus `agent-pod-claims` queue depth | -| `30-session-router.yaml` | `Deployment` + `Service` + `HTTPScaledObject` + `Role`/`RoleBinding` | Router itself + KEDA HTTP Add-on for RPS-based scaling. Cross-namespace RBAC lets the router patch agent-pool pods | +| `00-namespaces.yaml` | 3 × `Namespace` (PSA-restricted) | `session-control`, `platform`, `agent-sandboxes`. Each carries `pod-security.kubernetes.io/enforce: restricted` so Pod Security Admission rejects privileged pods at admission time | +| `05-serviceaccounts.yaml` | `ServiceAccount` | model-gateway SA, federated to an Azure user-assigned managed identity via `azure.workload.identity/client-id`. (The orchestrator SA is created in `35-…`.) | +| `35-sandbox-orchestrator.yaml` | `ServiceAccount` + `Deployment` + `Service` + `Role`/`RoleBinding` | The orchestrator. Provisions ONE agent sandbox per request (claim → run → teardown). RBAC targets the agent-sandbox CRDs + pods/exec in the sandbox namespace | +| `36-sandbox-template.yaml` | `SandboxTemplate` + `SandboxWarmPool` | The warm pool. Stamps the agent-pod image into pre-warmed sandboxes (Foundry env, writable workspace, PSA-clean). Sub-2s allocation via claim adoption | | `40-model-gateway.yaml` | `Deployment` + `Service` + `ConfigMap` | LiteLLM proxy + its `config.yaml` (model list, budgets, pass-through `/anthropic` endpoint) | -| `50-network-policies.yaml` | 3 × `NetworkPolicy` | Default-deny everywhere; agent pods can ONLY reach `model-gateway` + DNS; gateway can reach Foundry on 443 | +| `50-network-policies.yaml` | `NetworkPolicy` | Sandboxes can ONLY reach `model-gateway` + DNS; gateway can reach Foundry on 443 | + +> The legacy `20-agent-pod.yaml` (warm-pool Deployment + KEDA) and +> `30-session-router.yaml` were removed when the chart fully migrated to the +> Sandbox Orchestrator. The agent-pod **image** lives on — it's what the +> `SandboxTemplate` runs. ## Values you'll touch most @@ -27,14 +32,14 @@ global: sonnet: claude-sonnet-4-6 haiku: claude-haiku-4-5 -agentPod: - replicas: 80 # warm-pool baseline (ignored if KEDA min > this) - keda: - minReplicas: 80 - maxReplicas: 400 - -sessionRouter: - keda.httpAddon.targetPendingRequests: 50 +sandboxOrchestrator: + backend: direct # direct | sdk | fake + sandbox: + warmpoolReplicas: 2 # pre-warmed sandboxes kept Ready + useWarmpool: true # claim-adopt a warm pod (sub-2s) + concurrency: + maxTotal: 100 + maxPerDev: 3 modelGateway: budgets.default.maxBudgetUsd: 50 @@ -53,14 +58,13 @@ modelGateway: ```bash # Dry-run with synthetic IDs make chart-template | yq 'select(.kind=="Deployment") | .metadata.name' -# → agent-pod, session-router, model-gateway +# → sandbox-orchestrator, model-gateway # Full render to a file for inspection helm template demo charts/code-forge \ -f charts/code-forge/values-prod.yaml \ --set global.azureTenantId=$(uuidgen) \ - --set workloadIdentity.agentPod.clientId=$(uuidgen) \ - --set workloadIdentity.sessionRouter.clientId=$(uuidgen) \ + --set workloadIdentity.sandboxOrchestrator.clientId=$(uuidgen) \ --set workloadIdentity.modelGateway.clientId=$(uuidgen) \ > /tmp/render.yaml ``` @@ -72,15 +76,18 @@ helm upgrade --install code-forge charts/code-forge \ --create-namespace -n session-control \ -f charts/code-forge/values-prod.yaml \ --set global.azureTenantId=$AZ_TENANT \ - --set workloadIdentity.agentPod.clientId=$AGENT_MI \ - --set workloadIdentity.sessionRouter.clientId=$ROUTER_MI \ - --set workloadIdentity.modelGateway.clientId=$GATEWAY_MI \ - --set agentPod.keda.serviceBus.namespace=$SB_NAMESPACE + --set workloadIdentity.sandboxOrchestrator.clientId=$ORCH_MI \ + --set workloadIdentity.modelGateway.clientId=$GATEWAY_MI ``` ## Common pitfalls -- **`HTTPScaledObject` not found** → install KEDA HTTP Add-on first: `helm install http-add-on kedacore/keda-add-ons-http -n keda`. -- **`ScaledObject` Service Bus auth failing** → make sure `keda-azure-identity` `TriggerAuthentication` exists and KEDA's MI has `Azure Service Bus Data Owner` on the queue. -- **Agent pods stuck `Pending`** → spot capacity exhausted. Check `kubectl describe pod` for the `FailedScheduling` event; bump on-demand fallback in the AKS node-pool spec. -- **Model gateway 401 to Foundry** → `refresh-aad-token.py` failed; check the gateway pod logs for the `[refresh-aad]` line. Usually a missing federation between the gateway MI and its ServiceAccount. +- **SandboxTemplate/WarmPool CRDs not found** → install the agent-sandbox + extensions (v0.4.6 `extensions.yaml`) first, or set + `sandboxOrchestrator.sandbox.provisionTemplate=false` on a minimal cluster. +- **Updating the SandboxTemplate doesn't refresh live warm pods** → delete the + warm Sandbox CRs by name (`kubectl delete sandbox -n agent-sandboxes `) + to force the pool to re-stamp from the new template. +- **Model gateway 401 to Foundry** → `refresh-aad-token.py` failed; check the + gateway pod logs for the `[refresh-aad]` line. Usually a missing federation + between the gateway MI and its ServiceAccount. diff --git a/charts/code-forge/Chart.yaml b/charts/code-forge/Chart.yaml index 01aa099..8c02a19 100644 --- a/charts/code-forge/Chart.yaml +++ b/charts/code-forge/Chart.yaml @@ -2,9 +2,8 @@ apiVersion: v2 name: code-forge description: | Code Forge — multi-tenant Claude Code on AKS, fronted by Microsoft Foundry. - Ships: ephemeral agent-pod warm pool, session-router, LiteLLM model gateway, - KEDA HTTP Add-on scaling, NetworkPolicy egress lockdown, workload-identity - Foundry auth. + Ships: sandbox-orchestrator with an agent-sandbox warm pool, LiteLLM model + gateway, NetworkPolicy egress lockdown, workload-identity Foundry auth. type: application version: 0.1.0 appVersion: "1.0.0" diff --git a/charts/code-forge/README.md b/charts/code-forge/README.md index 50c3a98..eb375e5 100644 --- a/charts/code-forge/README.md +++ b/charts/code-forge/README.md @@ -5,10 +5,10 @@ Built around three primitives: ``` ┌─────────────────────────────────────────────────────────────┐ -│ developer ──HTTP──▶ session-router ──k8s patch──▶ pod │ +│ developer ──HTTP──▶ sandbox-orchestrator ──claim──▶ pod │ │ │ │ │ │ ▼ ▼ │ -│ Redis + Cosmos Claude Code│ +│ warm pool (CRD) Claude Code│ │ │ │ │ ▼ │ │ model-gateway │ @@ -18,12 +18,13 @@ Built around three primitives: └─────────────────────────────────────────────────────────────┘ ``` -- **Agent pod** — Ubuntu devcontainer image with Claude Code installed via the - official `ghcr.io/anthropics/devcontainer-features/claude-code:1.0` Feature. - Runs as non-root, ephemeral `/workspace`, idle timeout, label-driven state - machine (`warm` → `bound` → `cooldown`). -- **Session router** — Go service that maps `(dev_id, project_id)` to a free - warm pod, mints a per-session virtual key, and patches pod labels. +- **Agent sandbox** — Ubuntu devcontainer image with Claude Code installed via + the official `ghcr.io/anthropics/devcontainer-features/claude-code:1.0` + Feature. Runs as non-root with a writable `/workspace` under a read-only root + filesystem. Pre-warmed by a `SandboxWarmPool` for sub-2s allocation. +- **Sandbox orchestrator** — Python service that provisions ONE agent sandbox + per request (claim a warm pod → run → teardown) via the agent-sandbox CRDs, + driving I/O over the kube-apiserver exec stream. - **Model gateway** — LiteLLM proxy in front of Foundry. Translates the Anthropic Messages API used by Claude Code, holds an AAD token refreshed via Workload Identity, and enforces per-dev budgets / rate limits. @@ -37,8 +38,8 @@ charts/code-forge/ templates/ 00-namespaces.yaml # PSA-restricted namespaces 05-serviceaccounts.yaml # workload-identity SAs - 20-agent-pod.yaml # warm pool + KEDA service-bus scaler - 30-session-router.yaml # router + KEDA HTTP Add-on RPS scaler + RBAC + 35-sandbox-orchestrator.yaml # orchestrator Deployment + Service + RBAC + 36-sandbox-template.yaml # SandboxTemplate + SandboxWarmPool 40-model-gateway.yaml # LiteLLM + ConfigMap 50-network-policies.yaml # default-deny + tight allow-list ``` @@ -53,10 +54,10 @@ containers/ agent-entrypoint # idle watchdog + healthz agent-shutdown # preStop scrub healthz.py - session-router/ - main.go # claim/release loop (Go + client-go + Redis) - Dockerfile # distroless multi-stage - go.mod + sandbox-orchestrator/ + sandbox_orchestrator/ # FastAPI app + backend adapters + Dockerfile + pyproject.toml model-gateway/ Dockerfile # LiteLLM + AAD-token sidecar refresh-aad-token.py @@ -68,16 +69,15 @@ containers/ ### 0. Prereqs - AKS cluster with Workload Identity + OIDC issuer enabled. -- KEDA installed (`keda` namespace) — core scaler + HTTP Add-on. +- agent-sandbox controller + extensions CRDs (v0.4.6) installed + (`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`). - Azure resources: ACR, Foundry resource with Claude deployments (`claude-opus-4-8`, `claude-sonnet-4-6`, `claude-haiku-4-5`), - Service Bus namespace + queue `agent-pod-claims`, Cosmos DB - `codeforge` / `sessions`, Redis (Azure Cache for Redis or in-cluster), one user-assigned managed identity per role: - agent-pod, session-router, model-gateway, KEDA. + sandbox-orchestrator, model-gateway. - Federate each MI to the corresponding K8s ServiceAccount via `az identity federated-credential create`. -- Grant the agent-pod and model-gateway MIs the **Azure AI User** role +- Grant the model-gateway MI the **Azure AI User** role on the Foundry resource (or the custom role from the Foundry docs). ### 1. Build & push images @@ -88,12 +88,12 @@ ACR=acrtheclouds.azurecr.io az acr login -n acrtheclouds -docker build -t $ACR/code-forge/agent-pod:$TAG containers/agent-pod -docker build -t $ACR/code-forge/session-router:$TAG containers/session-router -docker build -t $ACR/code-forge/model-gateway:$TAG containers/model-gateway +docker build -t $ACR/code-forge/agent-pod:$TAG containers/agent-pod +docker build -t $ACR/code-forge/sandbox-orchestrator:$TAG containers/sandbox-orchestrator +docker build -t $ACR/code-forge/model-gateway:$TAG containers/model-gateway docker push $ACR/code-forge/agent-pod:$TAG -docker push $ACR/code-forge/session-router:$TAG +docker push $ACR/code-forge/sandbox-orchestrator:$TAG docker push $ACR/code-forge/model-gateway:$TAG ``` @@ -105,22 +105,19 @@ helm upgrade --install code-forge charts/code-forge \ -f charts/code-forge/values-prod.yaml \ --set global.azureTenantId=$(az account show --query tenantId -o tsv) \ --set global.foundry.resource=codeforge-foundry-westus3 \ - --set workloadIdentity.agentPod.clientId=$AGENT_MI_CLIENT_ID \ - --set workloadIdentity.sessionRouter.clientId=$ROUTER_MI_CLIENT_ID \ - --set workloadIdentity.modelGateway.clientId=$GATEWAY_MI_CLIENT_ID \ - --set agentPod.keda.serviceBus.namespace=codeforge-bus.servicebus.windows.net \ - --set agentPod.keda.serviceBus.identityClientId=$KEDA_MI_CLIENT_ID + --set workloadIdentity.sandboxOrchestrator.clientId=$ORCH_MI_CLIENT_ID \ + --set workloadIdentity.modelGateway.clientId=$GATEWAY_MI_CLIENT_ID ``` ### 3. Verify ```bash # Warm pool fully ready? -kubectl -n agent-pool get pods -l state=warm +kubectl -n agent-sandboxes get sandboxwarmpool -# Router reachable? -kubectl -n session-control port-forward svc/session-router 8080:8080 -curl -X POST localhost:8080/sessions -d '{"dev_id":"alice","project_id":"web"}' +# Orchestrator reachable? +kubectl -n session-control port-forward svc/sandbox-orchestrator 8080:8080 +curl -X POST localhost:8080/v1/sandboxes -d '{"dev_id":"alice","command":"echo hi"}' # Model gateway healthy? kubectl -n platform port-forward svc/model-gateway 4000:80 @@ -164,5 +161,4 @@ code . # opens VS Code, Reopen in Container, claude is installed - [Claude Code dev container docs](https://code.claude.com/docs/en/devcontainer) - [Claude Code on Microsoft Foundry](https://code.claude.com/docs/en/microsoft-foundry) - [Claude Code LLM gateway](https://code.claude.com/docs/en/llm-gateway) -- [KEDA HTTP Add-on](https://kedacore.github.io/http-add-on/) - [Azure Workload Identity](https://azure.github.io/azure-workload-identity/docs/) diff --git a/charts/code-forge/templates/00-namespaces.yaml b/charts/code-forge/templates/00-namespaces.yaml index 92ea5b6..2b80726 100644 --- a/charts/code-forge/templates/00-namespaces.yaml +++ b/charts/code-forge/templates/00-namespaces.yaml @@ -1,4 +1,4 @@ -{{- range $ns := list .Values.namespaces.sessionControl .Values.namespaces.agentPool .Values.namespaces.platform }} +{{- range $ns := list .Values.namespaces.sessionControl .Values.namespaces.platform .Values.sandboxOrchestrator.sandbox.namespace }} --- apiVersion: v1 kind: Namespace diff --git a/charts/code-forge/templates/05-serviceaccounts.yaml b/charts/code-forge/templates/05-serviceaccounts.yaml index e59c564..4fe2e0f 100644 --- a/charts/code-forge/templates/05-serviceaccounts.yaml +++ b/charts/code-forge/templates/05-serviceaccounts.yaml @@ -1,6 +1,6 @@ {{- if .Values.workloadIdentity.enabled }} -{{- range $key, $cfg := dict "agentPod" .Values.workloadIdentity.agentPod "sessionRouter" .Values.workloadIdentity.sessionRouter "modelGateway" .Values.workloadIdentity.modelGateway }} -{{- $ns := ternary $.Values.namespaces.agentPool (ternary $.Values.namespaces.sessionControl $.Values.namespaces.platform (eq $key "sessionRouter")) (eq $key "agentPod") }} +{{- range $key, $cfg := dict "modelGateway" .Values.workloadIdentity.modelGateway }} +{{- $ns := $.Values.namespaces.platform }} --- apiVersion: v1 kind: ServiceAccount diff --git a/charts/code-forge/templates/20-agent-pod.yaml b/charts/code-forge/templates/20-agent-pod.yaml deleted file mode 100644 index f06d5da..0000000 --- a/charts/code-forge/templates/20-agent-pod.yaml +++ /dev/null @@ -1,134 +0,0 @@ ---- -apiVersion: v1 -kind: ResourceQuota -metadata: - name: pool-quota - namespace: {{ .Values.namespaces.agentPool }} -spec: - hard: - pods: "500" - requests.cpu: "500" - requests.memory: 1000Gi - persistentvolumeclaims: "100" ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent-pod - namespace: {{ .Values.namespaces.agentPool }} - labels: - app: agent-pod - {{- include "code-forge.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.agentPod.replicas }} - selector: - matchLabels: - app: agent-pod - template: - metadata: - labels: - app: agent-pod - state: warm - azure.workload.identity/use: "true" - spec: - serviceAccountName: {{ .Values.workloadIdentity.agentPod.serviceAccountName }} - automountServiceAccountToken: true - terminationGracePeriodSeconds: 30 - nodeSelector: - {{- toYaml .Values.agentPod.nodeSelector | nindent 8 }} - tolerations: - {{- toYaml .Values.agentPod.tolerations | nindent 8 }} - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - {{- if .Values.agentPod.egressFirewall.enabled }} - initContainers: - - name: egress-firewall - image: {{ .Values.global.registry }}/code-forge/egress-firewall:1.0.0 - securityContext: - capabilities: { add: ["NET_ADMIN"] } - runAsUser: 0 - env: - - name: ALLOW_LIST - value: "{{ join "," .Values.agentPod.egressFirewall.allowList }}" - {{- end }} - containers: - - name: agent - image: {{ .Values.global.registry }}/{{ .Values.agentPod.image.repository }}:{{ .Values.agentPod.image.tag }} - imagePullPolicy: {{ .Values.agentPod.image.pullPolicy }} - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: { drop: ["ALL"] } - env: - {{- include "code-forge.claudeCodeFoundryEnv" . | nindent 12 }} - - name: SESSION_ID - valueFrom: { fieldRef: { fieldPath: "metadata.labels['session-id']" } } - - name: DEV_ID - valueFrom: { fieldRef: { fieldPath: "metadata.labels['dev-id']" } } - - name: POD_NAME - valueFrom: { fieldRef: { fieldPath: "metadata.name" } } - - name: IDLE_TIMEOUT_SECONDS - value: "{{ .Values.agentPod.idleTimeoutSeconds }}" - # Claude Code config dir — overridden so we can mount the persistent volume here. - - name: CLAUDE_CONFIG_DIR - value: /workspace/.claude - resources: - {{- toYaml .Values.agentPod.resources | nindent 12 }} - volumeMounts: - - name: workspace - mountPath: /workspace - - name: tmp - mountPath: /tmp - - name: cache - mountPath: /home/agent/.cache - readinessProbe: - httpGet: { path: /healthz, port: 8081 } - periodSeconds: 5 - livenessProbe: - httpGet: { path: /healthz, port: 8081 } - initialDelaySeconds: 20 - periodSeconds: 30 - # Pre-stop hook flushes session state + scrubs workspace so spot eviction is safe. - lifecycle: - preStop: - exec: - command: ["/usr/local/bin/agent-shutdown"] - volumes: - - name: workspace - ephemeral: - volumeClaimTemplate: - spec: - accessModes: [ "ReadWriteOnce" ] - storageClassName: {{ .Values.agentPod.workspace.storageClass }} - resources: { requests: { storage: "{{ .Values.agentPod.workspace.size }}" } } - - name: tmp - emptyDir: { sizeLimit: 2Gi } - - name: cache - emptyDir: { sizeLimit: 4Gi } -{{- if .Values.agentPod.keda.enabled }} ---- -apiVersion: keda.sh/v1alpha1 -kind: ScaledObject -metadata: - name: agent-pod - namespace: {{ .Values.namespaces.agentPool }} -spec: - scaleTargetRef: - name: agent-pod - pollingInterval: {{ .Values.agentPod.keda.pollingIntervalSeconds }} - cooldownPeriod: {{ .Values.agentPod.keda.cooldownPeriodSeconds }} - minReplicaCount: {{ .Values.agentPod.keda.minReplicas }} - maxReplicaCount: {{ .Values.agentPod.keda.maxReplicas }} - triggers: - - type: azure-servicebus - metadata: - namespace: {{ .Values.agentPod.keda.serviceBus.namespace | quote }} - queueName: {{ .Values.agentPod.keda.serviceBus.queue | quote }} - messageCount: {{ .Values.agentPod.keda.serviceBus.messageCount | quote }} - authenticationRef: - name: keda-azure-identity -{{- end }} diff --git a/charts/code-forge/templates/30-session-router.yaml b/charts/code-forge/templates/30-session-router.yaml deleted file mode 100644 index 31dbb0a..0000000 --- a/charts/code-forge/templates/30-session-router.yaml +++ /dev/null @@ -1,134 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: session-router - namespace: {{ .Values.namespaces.sessionControl }} - labels: - app: session-router - {{- include "code-forge.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.sessionRouter.replicas }} - selector: { matchLabels: { app: session-router } } - template: - metadata: - labels: - app: session-router - azure.workload.identity/use: "true" - spec: - serviceAccountName: {{ .Values.workloadIdentity.sessionRouter.serviceAccountName }} - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 - seccompProfile: { type: RuntimeDefault } - containers: - - name: router - image: {{ .Values.global.registry }}/{{ .Values.sessionRouter.image.repository }}:{{ .Values.sessionRouter.image.tag }} - imagePullPolicy: {{ .Values.sessionRouter.image.pullPolicy }} - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: { drop: ["ALL"] } - ports: - - containerPort: 8080 - name: http - env: - - name: REDIS_HOST - value: "{{ .Values.sessionRouter.redis.host }}" - - name: REDIS_PORT - value: "{{ .Values.sessionRouter.redis.port }}" - - name: COSMOS_ENDPOINT - value: "{{ .Values.sessionRouter.cosmosdb.endpoint }}" - - name: COSMOS_DATABASE - value: "{{ .Values.sessionRouter.cosmosdb.database }}" - - name: COSMOS_CONTAINER - value: "{{ .Values.sessionRouter.cosmosdb.container }}" - - name: AGENT_POOL_NAMESPACE - value: "{{ .Values.namespaces.agentPool }}" - - name: MODEL_GATEWAY_URL - value: "http://model-gateway.{{ .Values.namespaces.platform }}.svc.cluster.local" - resources: - {{- toYaml .Values.sessionRouter.resources | nindent 12 }} - readinessProbe: - httpGet: { path: /healthz, port: 8080 } - livenessProbe: - httpGet: { path: /healthz, port: 8080 } - initialDelaySeconds: 15 ---- -apiVersion: v1 -kind: Service -metadata: - name: session-router - namespace: {{ .Values.namespaces.sessionControl }} -spec: - selector: { app: session-router } - ports: - - port: {{ .Values.sessionRouter.service.port }} - targetPort: 8080 - name: http -{{- if and .Values.sessionRouter.keda.enabled .Values.sessionRouter.keda.httpAddon.enabled }} ---- -# KEDA HTTP Add-on — RPS-based scaling for the router. -# Buffers requests during cold-start; scales-to-zero off-hours. -apiVersion: http.keda.sh/v1alpha1 -kind: HTTPScaledObject -metadata: - name: session-router - namespace: {{ .Values.namespaces.sessionControl }} -spec: - hosts: - {{- range .Values.sessionRouter.keda.httpAddon.hosts }} - - {{ . | quote }} - {{- end }} - pathPrefixes: - {{- range .Values.sessionRouter.keda.httpAddon.pathPrefixes }} - - {{ . | quote }} - {{- end }} - scaleTargetRef: - name: session-router - kind: Deployment - apiVersion: apps/v1 - service: session-router - port: {{ .Values.sessionRouter.service.port }} - replicas: - min: {{ .Values.sessionRouter.replicas }} - max: 50 - scaledownPeriod: {{ .Values.sessionRouter.keda.httpAddon.scaledownPeriod }} - scalingMetric: - requestRate: - granularity: 1s - targetValue: {{ .Values.sessionRouter.keda.httpAddon.targetPendingRequests }} - window: 1m -{{- end }} ---- -# Session router needs RBAC to label/patch agent pods (warm → bound → cooldown). -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: session-router-pod-binder - namespace: {{ .Values.namespaces.agentPool }} -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "patch", "update"] - - apiGroups: [""] - resources: ["pods/exec"] - verbs: ["create"] - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "create", "update", "patch", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: session-router-pod-binder - namespace: {{ .Values.namespaces.agentPool }} -subjects: - - kind: ServiceAccount - name: {{ .Values.workloadIdentity.sessionRouter.serviceAccountName }} - namespace: {{ .Values.namespaces.sessionControl }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: session-router-pod-binder diff --git a/charts/code-forge/templates/35-sandbox-orchestrator.yaml b/charts/code-forge/templates/35-sandbox-orchestrator.yaml index 646231c..32cde92 100644 --- a/charts/code-forge/templates/35-sandbox-orchestrator.yaml +++ b/charts/code-forge/templates/35-sandbox-orchestrator.yaml @@ -64,8 +64,26 @@ spec: value: {{ $so.connectionMode | quote }} - name: SANDBOX_NAMESPACE value: {{ $so.sandbox.namespace | quote }} + - name: SANDBOX_TEMPLATE + value: {{ $so.sandbox.templateName | quote }} + - name: SANDBOX_USE_WARMPOOL + value: {{ $so.sandbox.useWarmpool | quote }} + - name: SANDBOX_IMAGE + value: {{ $so.sandbox.image | default (printf "%s/%s:%s" .Values.global.registry .Values.agentPod.image.repository .Values.agentPod.image.tag) | quote }} + - name: SANDBOX_RUN_AS_USER + value: {{ $so.sandbox.runAsUser | quote }} + - name: SANDBOX_CPU_REQUEST + value: {{ $so.sandbox.resources.requests.cpu | quote }} + - name: SANDBOX_CPU_LIMIT + value: {{ $so.sandbox.resources.limits.cpu | quote }} + - name: SANDBOX_MEMORY_REQUEST + value: {{ $so.sandbox.resources.requests.memory | quote }} + - name: SANDBOX_MEMORY_LIMIT + value: {{ $so.sandbox.resources.limits.memory | quote }} - name: SANDBOX_WARMPOOL value: {{ $so.sandbox.warmpool | quote }} + - name: SANDBOX_READY_TIMEOUT + value: {{ $so.sandbox.readyTimeout | quote }} - name: SANDBOX_TTL_SECONDS value: {{ $so.sandbox.ttlSeconds | quote }} - name: SANDBOX_COMMAND_TIMEOUT @@ -115,19 +133,28 @@ metadata: name: sandbox-orchestrator-sandbox-manager namespace: {{ $so.sandbox.namespace }} rules: + # Core CRD (kind Sandbox) lives in agents.x-k8s.io. - apiGroups: [{{ $so.sandbox.apiGroup | quote }}] - resources: ["sandboxes", "sandboxclaims"] + resources: ["sandboxes"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [{{ $so.sandbox.apiGroup | quote }}] + # Claims/templates/warmpools (the "sdk" backend flow) live in the extensions + # group. The orchestrator creates+deletes claims; templates/warmpools are + # provisioned by the chart so it only needs to read them. + - apiGroups: [{{ $so.sandbox.extensionsApiGroup | quote }}] + resources: ["sandboxclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [{{ $so.sandbox.extensionsApiGroup | quote }}] resources: ["sandboxtemplates", "sandboxwarmpools"] verbs: ["get", "list", "watch"] # The SDK reads sandbox pod status / streams exec to run commands. - apiGroups: [""] resources: ["pods", "pods/log"] verbs: ["get", "list", "watch"] + # The Kubernetes Python client streams exec over a GET (websocket upgrade), + # so the orchestrator needs BOTH get and create on pods/exec. - apiGroups: [""] resources: ["pods/exec", "pods/portforward"] - verbs: ["create"] + verbs: ["get", "create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/charts/code-forge/templates/36-sandbox-template.yaml b/charts/code-forge/templates/36-sandbox-template.yaml new file mode 100644 index 0000000..750d5ee --- /dev/null +++ b/charts/code-forge/templates/36-sandbox-template.yaml @@ -0,0 +1,103 @@ +{{- $so := .Values.sandboxOrchestrator }} +{{- $sandboxImage := $so.sandbox.image | default (printf "%s/%s:%s" .Values.global.registry .Values.agentPod.image.repository .Values.agentPod.image.tag) }} +# ============================================================================= +# Agent-sandbox extensions resources (require the v0.4.6 extensions.yaml CRDs: +# SandboxTemplate + SandboxWarmPool in extensions.agents.x-k8s.io). +# +# SandboxTemplate — the reusable pod shape every agent sandbox is stamped +# from (agent-pod image, PodSecurity "restricted"-clean). +# SandboxWarmPool — keeps N sandboxes pre-Ready so a SandboxClaim binds in +# ~sub-2s instead of cold-starting a pod. This is the +# primitive that turns one-shot runs into warm sessions. +# +# Gated on sandboxOrchestrator.sandbox.provisionTemplate so clusters running the +# minimal "direct" backend (no extensions CRDs) can skip these cleanly. +# ============================================================================= +{{- if $so.sandbox.provisionTemplate }} +apiVersion: {{ $so.sandbox.extensionsApiGroup }}/v1alpha1 +kind: SandboxTemplate +metadata: + name: {{ $so.sandbox.templateName }} + namespace: {{ $so.sandbox.namespace }} + labels: + app: agent-sandbox + {{- include "code-forge.labels" . | nindent 4 }} +spec: + # v0.4.6 made the per-sandbox headless Service opt-in. The orchestrator routes + # via pod exec, so default off. + service: {{ $so.sandbox.service }} + podTemplate: + metadata: + labels: + app: agent-sandbox + code-forge.io/pool: {{ $so.sandbox.warmpool }} + spec: + # The sandbox namespace enforces PodSecurity "restricted": run as non-root, + # drop all capabilities, forbid privilege escalation, set a seccomp profile. + # fsGroup makes the writable emptyDir volumes group-owned by the agent + # user (the agent-pod image's `agent` is uid=gid={{ $so.sandbox.runAsUser }}), + # so Claude Code and staged files can write to /workspace, /tmp, and the cache. + securityContext: + runAsNonRoot: true + runAsUser: {{ $so.sandbox.runAsUser }} + fsGroup: {{ $so.sandbox.runAsUser }} + seccompProfile: { type: RuntimeDefault } + containers: + - name: {{ $so.sandbox.containerName }} + image: {{ $sandboxImage }} + # Keep the container idle; the orchestrator execs work into it. We do + # NOT run the agent-pod's `agent-entrypoint` here on purpose — that + # script self-terminates after IDLE_TIMEOUT, but warm-pool pods must + # stay up until the SandboxWarmPool controller recycles them. + command: ["/bin/sh", "-c", "sleep infinity"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: { drop: ["ALL"] } + seccompProfile: { type: RuntimeDefault } + env: + # Claude Code → in-cluster model gateway (NOT Foundry directly). This + # is what makes a claimed sandbox a working agent rather than a bare + # idle container. + {{- include "code-forge.claudeCodeFoundryEnv" . | nindent 12 }} + - name: POD_NAME + valueFrom: { fieldRef: { fieldPath: "metadata.name" } } + # Redirect Claude's config to the writable workspace volume so it + # works under readOnlyRootFilesystem (mirrors the agent-pod). + - name: CLAUDE_CONFIG_DIR + value: /workspace/.claude + resources: + {{- toYaml $so.sandbox.resources | nindent 12 }} + # Writable mounts mirror the agent-pod so Claude Code can run with a + # read-only root filesystem. + volumeMounts: + - name: workspace + mountPath: /workspace + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /home/agent/.cache + # emptyDir (not an ephemeral PVC like the agent-pod) keeps warm-pool spin-up + # fast and storage-free; sandboxes are short-lived and scrubbed on teardown. + volumes: + - name: workspace + emptyDir: { sizeLimit: {{ $so.sandbox.workspaceSize }} } + - name: tmp + emptyDir: { sizeLimit: 2Gi } + - name: cache + emptyDir: { sizeLimit: 4Gi } +--- +apiVersion: {{ $so.sandbox.extensionsApiGroup }}/v1alpha1 +kind: SandboxWarmPool +metadata: + name: {{ $so.sandbox.warmpool }} + namespace: {{ $so.sandbox.namespace }} + labels: + app: agent-sandbox + {{- include "code-forge.labels" . | nindent 4 }} +spec: + replicas: {{ $so.sandbox.warmpoolReplicas }} + sandboxTemplateRef: + name: {{ $so.sandbox.templateName }} +{{- end }} diff --git a/charts/code-forge/templates/50-network-policies.yaml b/charts/code-forge/templates/50-network-policies.yaml index b999359..f667650 100644 --- a/charts/code-forge/templates/50-network-policies.yaml +++ b/charts/code-forge/templates/50-network-policies.yaml @@ -1,35 +1,16 @@ {{- if .Values.networkPolicies.enabled }} --- -# Default-deny ingress + egress for the agent pool. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: default-deny - namespace: {{ .Values.namespaces.agentPool }} -spec: - podSelector: {} - policyTypes: ["Ingress", "Egress"] ---- -# Allow agent → model-gateway (egress) and ingress from session-router. +# Model gateway can reach Azure Foundry (egress) on 443; ingress from cluster only. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: agent-pod-allow - namespace: {{ .Values.namespaces.agentPool }} + name: model-gateway-egress + namespace: {{ .Values.namespaces.platform }} spec: podSelector: - matchLabels: { app: agent-pod } - policyTypes: ["Ingress", "Egress"] - ingress: - # Router opens a session via http or kubectl exec; both arrive from session-control. - - from: - - namespaceSelector: - matchLabels: { kubernetes.io/metadata.name: {{ .Values.namespaces.sessionControl }} } - ports: - - { protocol: TCP, port: 8080 } - - { protocol: TCP, port: 8081 } + matchLabels: { app: model-gateway } + policyTypes: ["Egress"] egress: - # Cluster DNS. - to: - namespaceSelector: {} podSelector: @@ -37,27 +18,26 @@ spec: ports: - { protocol: UDP, port: 53 } - { protocol: TCP, port: 53 } - # Model gateway only — no direct Foundry egress, no public internet. - - to: - - namespaceSelector: - matchLabels: { kubernetes.io/metadata.name: {{ .Values.namespaces.platform }} } - podSelector: - matchLabels: { app: model-gateway } - ports: - - { protocol: TCP, port: 80 } - - { protocol: TCP, port: 4000 } + # Foundry + Azure AD STS. Restrict by FQDN at the firewall/private-endpoint layer. + - ports: + - { protocol: TCP, port: 443 } --- -# Model gateway can reach Azure Foundry (egress) on 443; ingress from cluster only. +# Agent sandboxes get the same default-deny egress posture as the agent pool: +# they may reach cluster DNS and the model gateway only — no direct Foundry, +# no public internet, no lateral movement. Ingress is left unrestricted because +# the orchestrator drives sandboxes via the kube-apiserver exec stream (which +# NetworkPolicy does not govern), not pod-to-pod traffic. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: model-gateway-egress - namespace: {{ .Values.namespaces.platform }} + name: agent-sandbox-egress + namespace: {{ .Values.sandboxOrchestrator.sandbox.namespace }} spec: podSelector: - matchLabels: { app: model-gateway } + matchLabels: { app: agent-sandbox } policyTypes: ["Egress"] egress: + # Cluster DNS. - to: - namespaceSelector: {} podSelector: @@ -65,7 +45,13 @@ spec: ports: - { protocol: UDP, port: 53 } - { protocol: TCP, port: 53 } - # Foundry + Azure AD STS. Restrict by FQDN at the firewall/private-endpoint layer. - - ports: - - { protocol: TCP, port: 443 } + # Model gateway only. + - to: + - namespaceSelector: + matchLabels: { kubernetes.io/metadata.name: {{ .Values.namespaces.platform }} } + podSelector: + matchLabels: { app: model-gateway } + ports: + - { protocol: TCP, port: 80 } + - { protocol: TCP, port: 4000 } {{- end }} diff --git a/charts/code-forge/values-prod.yaml b/charts/code-forge/values-prod.yaml index c5cea5f..db1076a 100644 --- a/charts/code-forge/values-prod.yaml +++ b/charts/code-forge/values-prod.yaml @@ -1,46 +1,24 @@ # Production overlay — copy and customize per environment. global: - registry: acrtheclouds.azurecr.io - azureTenantId: "" + registry: codeforgedemo.azurecr.io + azureTenantId: "61800350-09d8-4051-942c-5b732fcaa307" foundry: - resource: codeforge-foundry-westus3 + resource: admin-mgrojh0e-eastus2 models: - opus: claude-opus-4-8 - sonnet: claude-sonnet-4-6 - haiku: claude-haiku-4-5 - -agentPod: - replicas: 80 - resources: - requests: { cpu: "1500m", memory: "3Gi" } - limits: { cpu: "4000m", memory: "8Gi" } - workspace: - size: 20Gi - storageClass: managed-csi-premium - idleTimeoutSeconds: 900 - keda: - enabled: true - minReplicas: 80 - maxReplicas: 400 - serviceBus: - namespace: codeforge-bus.servicebus.windows.net - queue: agent-pod-claims - -sessionRouter: - replicas: 5 - cosmosdb: - endpoint: https://codeforge.documents.azure.com:443/ - keda: - enabled: true - httpAddon: - enabled: true - hosts: - - api.codeforge.example.com - targetPendingRequests: 50 - scaledownPeriod: 300 + opus: gpt-5.4-mini + sonnet: gpt-5-mini + haiku: gpt-4o +sandboxOrchestrator: + image: + tag: 1.0.2 + # The "direct" backend now provisions via a SandboxClaim that adopts a + # pre-warmed pod from the warm pool (sub-2s allocation) and drives it via pod + # exec. We stay on exec rather than the SDK's HTTP transport because the + # agent-pod image runs Claude Code, not the agent-sandbox runtime server. + backend: direct modelGateway: - replicas: 4 + replicas: 1 budgets: default: maxBudgetUsd: 50 diff --git a/charts/code-forge/values.yaml b/charts/code-forge/values.yaml index 29d606b..43502cd 100644 --- a/charts/code-forge/values.yaml +++ b/charts/code-forge/values.yaml @@ -4,32 +4,34 @@ # ============================================================================= global: - # Azure Container Registry that hosts agent-pod, session-router, model-gateway images. + # Azure Container Registry that hosts agent-pod, sandbox-orchestrator, model-gateway images. registry: acrtheclouds.azurecr.io # Azure tenant + workload identity client IDs (set per-environment). - azureTenantId: "" # e.g. 72f988bf-86f1-41af-91ab-2d7cd011db47 + azureTenantId: "" # e.g. 72f988bf-86f1-41af-91ab-2d7cd011db47 # Microsoft Foundry resource for Claude deployments. foundry: - resource: "" # e.g. codeforge-foundry-westus3 + resource: "" # e.g. codeforge-foundry-westus3 # If set, overrides resource-based URL construction. - baseUrl: "" # https://{resource}.services.ai.azure.com/anthropic + baseUrl: "" # https://{resource}.services.ai.azure.com/anthropic # Deployment names — must match Azure Foundry deployments (pin model versions!) models: - opus: "claude-opus-4-8" - sonnet: "claude-sonnet-4-6" - haiku: "claude-haiku-4-5" + opus: "claude-opus-4-8" + sonnet: "claude-sonnet-4-6" + haiku: "claude-haiku-4-5" # ------------------------------------------------------------------------- # Namespaces & isolation # ------------------------------------------------------------------------- namespaces: sessionControl: session-control - agentPool: agent-pool - platform: platform + platform: platform # ------------------------------------------------------------------------- -# Agent Pod (Claude Code on Foundry) -# Ephemeral, stateless. Router patches state=warm→bound on allocation. +# Agent Pod image (Claude Code on Foundry) +# The agent-pod is no longer deployed as a standalone warm-pool Deployment — +# the Sandbox Orchestrator's SandboxTemplate / SandboxWarmPool runs this image +# instead (see sandboxOrchestrator below). Only the image coordinates are kept +# here because the SandboxTemplate references them. # ------------------------------------------------------------------------- agentPod: image: @@ -37,98 +39,6 @@ agentPod: tag: 1.0.0 pullPolicy: IfNotPresent - # Baseline warm pool — KEDA overrides up to maxReplicas under load. - replicas: 80 - - resources: - requests: { cpu: "1500m", memory: "3Gi" } - limits: { cpu: "4000m", memory: "8Gi" } - - # Ephemeral scratch workspace — wiped between sessions. - workspace: - size: 10Gi - storageClass: managed-csi # azure-disk; swap for managed-csi-premium for IO-bound workloads - - # Idle session scrub (router writes state=cooldown; pod self-deletes after this). - idleTimeoutSeconds: 900 - - # Spot node affinity — agents are interruptible. - nodeSelector: - agentpool: spotagents - tolerations: - - key: kubernetes.azure.com/scalesetpriority - operator: Equal - value: spot - effect: NoSchedule - - # KEDA — Service Bus depth scaling. - keda: - enabled: true - minReplicas: 80 - maxReplicas: 400 - pollingIntervalSeconds: 15 - cooldownPeriodSeconds: 120 - serviceBus: - namespace: "" # codeforge-bus.servicebus.windows.net - queue: agent-pod-claims - messageCount: "1" # pods per queued claim - identityClientId: "" # KEDA workload identity client ID - - # Optional firewall init-container — restricts egress to a curated allow-list. - # Kept disabled by default; production deployments rely on Cilium/Calico NetworkPolicies. - egressFirewall: - enabled: false - # Domains Claude Code legitimately reaches; everything else is dropped. - allowList: - - api.anthropic.com - - "*.services.ai.azure.com" # Foundry - - "*.azurecr.io" - - registry-1.docker.io - - mcr.microsoft.com - - github.com - - "*.githubusercontent.com" - - registry.npmjs.org - -# ------------------------------------------------------------------------- -# Session Router -# Maps (dev_id, project_id) → available pod. Stateless; state in Redis. -# Scaled by KEDA HTTP Add-on (RPS-based, scale-to-zero off-hours). -# ------------------------------------------------------------------------- -sessionRouter: - image: - repository: code-forge/session-router - tag: 1.0.0 - pullPolicy: IfNotPresent - - replicas: 3 - resources: - requests: { cpu: "500m", memory: "512Mi" } - limits: { cpu: "2000m", memory: "2Gi" } - - service: - port: 8080 - - redis: - # Internal cluster DNS for session state cache. - host: redis.platform.svc.cluster.local - port: 6379 - - cosmosdb: - # Long-term session metadata + audit log. - endpoint: "" # https://codeforge.documents.azure.com:443/ - database: codeforge - container: sessions - - keda: - enabled: true - httpAddon: - enabled: true - hosts: - - api.codeforge.example.com - pathPrefixes: [/] - targetPendingRequests: 50 # buffer up to 50 in flight before scaling - scaledownPeriod: 300 - # ------------------------------------------------------------------------- # Sandbox Orchestrator # Sandbox-native successor to the session-router. Receives a request and @@ -138,34 +48,81 @@ sessionRouter: sandboxOrchestrator: image: repository: code-forge/sandbox-orchestrator - tag: 0.1.0 + tag: 1.0.0 pullPolicy: IfNotPresent replicas: 2 resources: - requests: { cpu: "250m", memory: "256Mi" } - limits: { cpu: "1000m", memory: "1Gi" } + requests: { cpu: "250m", memory: "256Mi" } + limits: { cpu: "1000m", memory: "1Gi" } service: port: 8080 - # Backend: "sdk" (real agent-sandbox client) or "fake" (in-memory; tests only). - backend: sdk + # Backend: "direct" (create Sandbox CRs + pod exec; matches the minimal + # agent-sandbox controller installed here), "sdk" (upstream claim/template/ + # warmpool client; needs those CRDs), or "fake" (in-memory; tests only). + backend: direct # SDK connection architecture: in-cluster | local-tunnel | gateway | direct. connectionMode: in-cluster sandbox: - # Namespace the Sandbox/SandboxClaim/SandboxWarmPool resources live in. + # Namespace the Sandbox resources live in. namespace: agent-sandboxes - # Default warm pool a request claims from when it doesn't name one. + # Container image each sandbox pod runs. Defaults to the agent-pod image so + # Claude Code is present inside the sandbox. Leave empty to fall back to the + # busybox smoke image baked into the orchestrator. + image: "" + # PodSecurity "restricted" forbids root; run as the sandbox image's own + # non-root user. The default agent-pod image creates `agent` as UID 1001 + # (UID 1000 is the base image's `vscode`) and owns /workspace, so the + # sandbox must run as 1001 to have a writable workspace. Keep this in sync + # with the SANDBOX_IMAGE's workspace owner. + runAsUser: 1001 + # Resource envelope stamped onto each sandbox container. + resources: + requests: { cpu: "250m", memory: "256Mi" } + limits: { cpu: "1000m", memory: "1Gi" } + # Container name inside each sandbox pod. The orchestrator execs into this + # container, so it must match what the SandboxTemplate / direct manifest + # stamps onto the pod. + containerName: sandbox + # Provision the SandboxTemplate + SandboxWarmPool from this chart. Requires + # the agent-sandbox extensions CRDs (v0.4.6 extensions.yaml) on the cluster. + # Set false on minimal clusters running only the "direct" backend. + provisionTemplate: true + # SandboxTemplate the warm pool (and "sdk" backend claims) are built from. + # Provisioned by the chart from the agent-pod image below. + templateName: python-sandbox-template + # When true, the "direct" backend provisions by claiming a pre-warmed pod + # from the warm pool (SandboxClaim adoption) instead of cold-creating a + # bare Sandbox CR. Gives sub-2s session-style allocation. + useWarmpool: true + # Default warm pool a request claims from when it doesn't name one + # (used by the "sdk" backend; ignored by "direct"). warmpool: python-sandbox-warmpool + # Number of pre-warmed sandboxes the controller keeps Ready in the pool. + # This is what makes session allocation sub-2s instead of a cold start. + warmpoolReplicas: 2 + # Opt into a headless Service per sandbox (v0.4.6 made this opt-in). The + # orchestrator routes via pod exec, so leave off unless you need DNS. + service: false + # Size limit for the sandbox's writable /workspace emptyDir (Claude Code's + # config dir + staged files live here under a read-only root filesystem). + workspaceSize: 8Gi + # How long to wait for a provisioned sandbox to report Ready. + readyTimeout: 180 # Controller-side TTL (SandboxClaim shutdownTime). Safety net if we crash. ttlSeconds: 3600 # Per-command execution timeout inside the sandbox. commandTimeout: 300 - # API group of the installed agent-sandbox CRDs. MUST match the CRDs on the + # API group of the core agent-sandbox CRD (kind Sandbox). MUST match the # cluster — used to scope the orchestrator's RBAC Role. apiGroup: agents.x-k8s.io + # API group of the agent-sandbox *extensions* CRDs (SandboxClaim, + # SandboxTemplate, SandboxWarmPool), installed from the v0.4.6 + # extensions.yaml. Needed by the "sdk" backend's claim/template/warmpool flow. + extensionsApiGroup: extensions.agents.x-k8s.io # Admission caps — exceeding either returns HTTP 429 (carried from the router). concurrency: @@ -188,8 +145,8 @@ modelGateway: replicas: 2 resources: - requests: { cpu: "500m", memory: "1Gi" } - limits: { cpu: "2000m", memory: "4Gi" } + requests: { cpu: "500m", memory: "1Gi" } + limits: { cpu: "2000m", memory: "4Gi" } service: port: 80 @@ -213,12 +170,6 @@ modelGateway: # ------------------------------------------------------------------------- workloadIdentity: enabled: true - agentPod: - serviceAccountName: agent-pod - clientId: "" # Foundry caller MI client ID - sessionRouter: - serviceAccountName: session-router - clientId: "" sandboxOrchestrator: serviceAccountName: sandbox-orchestrator clientId: "" diff --git a/containers/agent-pod/Dockerfile b/containers/agent-pod/Dockerfile index 542cdd3..b3bb570 100644 --- a/containers/agent-pod/Dockerfile +++ b/containers/agent-pod/Dockerfile @@ -47,7 +47,9 @@ COPY healthz.py /usr/local/bin/healthz.py RUN chmod +x /usr/local/bin/agent-shutdown /usr/local/bin/agent-entrypoint /usr/local/bin/healthz.py # ---- User & filesystem ------------------------------------------------------- -RUN useradd -m -u 1000 -s /bin/bash agent \ +RUN if ! id -u agent >/dev/null 2>&1; then \ + useradd -m -s /bin/bash agent; \ + fi \ && mkdir -p /workspace /workspace/.claude \ && chown -R agent:agent /workspace diff --git a/containers/model-gateway/Dockerfile b/containers/model-gateway/Dockerfile index 0a78b01..328d1e3 100644 --- a/containers/model-gateway/Dockerfile +++ b/containers/model-gateway/Dockerfile @@ -8,7 +8,8 @@ FROM ghcr.io/berriai/litellm:main-stable AS base USER root -RUN pip install --no-cache-dir azure-identity msal +RUN /app/.venv/bin/python -m ensurepip --upgrade \ + && /app/.venv/bin/python -m pip install --no-cache-dir azure-identity msal COPY refresh-aad-token.py /usr/local/bin/refresh-aad-token.py COPY entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/containers/sandbox-orchestrator/requirements.txt b/containers/sandbox-orchestrator/requirements.txt index b695eb9..8749a8c 100644 --- a/containers/sandbox-orchestrator/requirements.txt +++ b/containers/sandbox-orchestrator/requirements.txt @@ -8,3 +8,7 @@ uvicorn[standard]>=0.27,<1.0 # configs (in-cluster / gateway / local-tunnel / direct). Installed in the # image; the test-suite stubs it out via the fake backend so CI needs no cluster. k8s-agent-sandbox>=0.1.0 + +# Kubernetes client. A transitive dep of the SDK, but the "direct" backend uses +# it directly (create Sandbox CRs + pod exec), so pin it explicitly. +kubernetes>=29,<37 diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py b/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py index 5ccf437..96a6fc9 100644 --- a/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/backends.py @@ -19,7 +19,9 @@ from __future__ import annotations import abc +import os import shlex +import time import uuid from dataclasses import dataclass from typing import Any, Callable @@ -51,12 +53,15 @@ class SandboxBackend(abc.ABC): def create_sandbox( self, *, - warmpool: str, + template: str, + warmpool: str | None = None, labels: dict[str, str] | None = None, ttl_seconds: int | None = None, ) -> SandboxHandle: - """Claim a sandbox from ``warmpool`` and block until it is Ready. + """Provision a sandbox from ``template`` and block until it is Ready. + When ``warmpool`` is set the claim binds to a pre-warmed sandbox from + that pool (fast path); otherwise it cold-creates one from ``template``. Raises on timeout or provisioning failure; the caller is responsible for marking the request FAILED. """ @@ -129,15 +134,21 @@ def _build_client(config: Config): def create_sandbox( self, *, - warmpool: str, + template: str, + warmpool: str | None = None, labels: dict[str, str] | None = None, ttl_seconds: int | None = None, ) -> SandboxHandle: + # ``template`` is the SDK's only required arg: the claim sets + # ``spec.sandboxTemplateRef.name=template``. ``warmpool`` is optional and + # binds the claim to a pre-warmed sandbox from that pool when supplied. kwargs: dict = { - "warmpool": warmpool, + "template": template, "namespace": self._config.sandbox_namespace, "sandbox_ready_timeout": self._config.sandbox_ready_timeout, } + if warmpool: + kwargs["warmpool"] = warmpool if labels: kwargs["labels"] = labels ttl = ttl_seconds if ttl_seconds is not None else self._config.sandbox_ttl_seconds @@ -175,6 +186,313 @@ def terminate(self, handle: SandboxHandle) -> None: sandbox.terminate() +# --------------------------------------------------------------------------- +# Direct backend — create Sandbox CRs ourselves, drive them via pod exec +# --------------------------------------------------------------------------- + + +# API coordinates of the agent-sandbox ``Sandbox`` CRD installed on the cluster. +_SANDBOX_GROUP = "agents.x-k8s.io" +_SANDBOX_VERSION = "v1alpha1" +_SANDBOX_PLURAL = "sandboxes" +_SANDBOX_CONTAINER = "sandbox" + + +class DirectSandboxBackend(SandboxBackend): + """Provision sandboxes by writing ``Sandbox`` CRs straight to the cluster. + + The upstream ``SandboxClient`` (see :class:`SdkSandboxBackend`) drives a + ``SandboxClaim`` → ``SandboxTemplate`` → ``SandboxWarmPool`` pipeline. This + cluster only has the bare ``Sandbox`` CRD installed, so we talk to it + directly: create a ``Sandbox`` whose ``spec.podTemplate`` describes the + agent container, wait for the controller to flip its ``Ready`` condition, + then exec into the resulting pod (which is named after the Sandbox) for + file staging and command execution. Teardown deletes the CR; the controller + garbage-collects the pod. + """ + + def __init__(self, config: Config): + self._config = config + # Imported lazily so the module loads without the k8s client present. + from kubernetes import client, config as kconfig + + try: + kconfig.load_incluster_config() + except kconfig.ConfigException: + kconfig.load_kube_config() + self._k8s = client + self._core = client.CoreV1Api() + self._custom = client.CustomObjectsApi() + # Lazily-built SDK cluster helper for the warm-pool claim lifecycle. + self._claim_helper = None + + # -- provisioning ------------------------------------------------------ + + def create_sandbox( + self, + *, + template: str, + warmpool: str | None = None, + labels: dict[str, str] | None = None, + ttl_seconds: int | None = None, + ) -> SandboxHandle: + # Warm-pool path: create a SandboxClaim that adopts a pre-warmed pod from + # the pool (sub-2s, session-style), then drive it via pod exec. Falls + # back to cold-creating a bare Sandbox CR when warm pools are disabled. + if self._config.sandbox_use_warmpool and template: + return self._provision_via_claim(template, warmpool, labels, ttl_seconds) + return self._provision_direct_cr(labels) + + def _provision_direct_cr(self, labels: dict[str, str] | None) -> SandboxHandle: + name = f"cf-sbx-{uuid.uuid4().hex[:10]}" + namespace = self._config.sandbox_namespace + manifest = self._build_manifest(name, labels) + + self._custom.create_namespaced_custom_object( + group=_SANDBOX_GROUP, + version=_SANDBOX_VERSION, + namespace=namespace, + plural=_SANDBOX_PLURAL, + body=manifest, + ) + try: + self._wait_ready(name, namespace, self._config.sandbox_ready_timeout) + except Exception: + # Don't leak an orphaned Sandbox if it never came up. + self._delete(name, namespace) + raise + # The controller names the pod identically to the Sandbox CR. + return SandboxHandle(sandbox_id=name, claim_name=name, _native={"name": name, "namespace": namespace}) + + def _provision_via_claim( + self, + template: str, + warmpool: str | None, + labels: dict[str, str] | None, + ttl_seconds: int | None, + ) -> SandboxHandle: + # Reuse the upstream SDK's cluster helper for the claim lifecycle so we + # track exactly the SandboxClaim → Sandbox resolution the controller + # implements (incl. warm-pool adoption, where the sandbox name differs + # from the claim name). I/O still goes over pod exec, not the SDK's HTTP + # transport (the agent-pod image doesn't run the sandbox runtime server). + from k8s_agent_sandbox.k8s_helper import K8sHelper + from k8s_agent_sandbox.utils import construct_sandbox_claim_lifecycle_spec + + helper = self._claim_helper or K8sHelper() + self._claim_helper = helper + + namespace = self._config.sandbox_namespace + timeout = self._config.sandbox_ready_timeout + claim_name = f"cf-claim-{uuid.uuid4().hex[:10]}" + + ttl = ttl_seconds if ttl_seconds is not None else self._config.sandbox_ttl_seconds + lifecycle = ( + construct_sandbox_claim_lifecycle_spec(int(ttl)) if ttl and ttl > 0 else None + ) + + helper.create_sandbox_claim( + claim_name, + template, + namespace, + labels=labels, + lifecycle=lifecycle, + warmpool=warmpool, + ) + try: + start = time.monotonic() + sandbox_name = helper.resolve_sandbox_name(claim_name, namespace, timeout) + remaining = max(1, int(timeout - (time.monotonic() - start))) + helper.wait_for_sandbox_ready(sandbox_name, namespace, remaining) + except Exception: + # Deleting the claim returns/recycles any adopted warm sandbox. + helper.delete_sandbox_claim(claim_name, namespace) + raise + # The controller names the pod identically to the resolved Sandbox CR. + return SandboxHandle( + sandbox_id=sandbox_name, + claim_name=claim_name, + _native={"name": sandbox_name, "namespace": namespace, "claim": claim_name}, + ) + + + def _build_manifest(self, name: str, labels: dict[str, str] | None) -> dict: + cfg = self._config + metadata: dict[str, Any] = {"name": name} + if labels: + metadata["labels"] = labels + # The sandbox namespace enforces PodSecurity "restricted": every pod must + # run as non-root, drop all capabilities, forbid privilege escalation, + # and set a seccomp profile. + pod_security = { + "runAsNonRoot": True, + "runAsUser": cfg.sandbox_run_as_user, + "seccompProfile": {"type": "RuntimeDefault"}, + } + container_security = { + "allowPrivilegeEscalation": False, + "runAsNonRoot": True, + "capabilities": {"drop": ["ALL"]}, + "seccompProfile": {"type": "RuntimeDefault"}, + } + container = { + "name": _SANDBOX_CONTAINER, + "image": cfg.sandbox_image, + # Keep the container idle; the orchestrator execs work into it. + "command": ["/bin/sh", "-c", "sleep infinity"], + "securityContext": container_security, + "resources": { + "requests": {"cpu": cfg.sandbox_cpu_request, "memory": cfg.sandbox_memory_request}, + "limits": {"cpu": cfg.sandbox_cpu_limit, "memory": cfg.sandbox_memory_limit}, + }, + } + return { + "apiVersion": f"{_SANDBOX_GROUP}/{_SANDBOX_VERSION}", + "kind": "Sandbox", + "metadata": metadata, + "spec": { + "podTemplate": { + "spec": { + "securityContext": pod_security, + "containers": [container], + } + } + }, + } + + def _wait_ready(self, name: str, namespace: str, timeout: int) -> None: + """Block until the Sandbox reports ``Ready=True`` or the timeout lapses.""" + from kubernetes import watch + + deadline = time.monotonic() + timeout + while True: + remaining = int(deadline - time.monotonic()) + if remaining <= 0: + raise TimeoutError( + f"sandbox {name!r} did not become ready within {timeout}s" + ) + w = watch.Watch() + try: + for event in w.stream( + self._custom.list_namespaced_custom_object, + group=_SANDBOX_GROUP, + version=_SANDBOX_VERSION, + namespace=namespace, + plural=_SANDBOX_PLURAL, + field_selector=f"metadata.name={name}", + timeout_seconds=remaining, + ): + etype = event.get("type") + if etype == "DELETED": + w.stop() + raise RuntimeError(f"sandbox {name!r} was deleted before ready") + obj = event.get("object") or {} + status = obj.get("status") or {} + for cond in status.get("conditions", []): + if cond.get("type") == "Ready" and cond.get("status") == "True": + w.stop() + return + finally: + w.stop() + + # -- driving the sandbox ---------------------------------------------- + + def write_file(self, handle: SandboxHandle, path: str, content: str) -> None: + import base64 + + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + directory = os.path.dirname(path) or "." + # base64 in argv avoids any stdin/EOF dance over the exec websocket. + script = ( + f"mkdir -p {shlex.quote(directory)} && " + f"printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(path)}" + ) + result = self._exec(handle, ["/bin/sh", "-c", script], timeout=self._config.command_timeout) + if result.exit_code != 0: + raise RuntimeError( + f"failed to write {path!r} into sandbox " + f"{handle.sandbox_id} (exit {result.exit_code}): {result.stderr.strip()}" + ) + + def run(self, handle: SandboxHandle, command: str, timeout: int) -> ExecResult: + return self._exec(handle, ["/bin/sh", "-c", command], timeout=timeout) + + def terminate(self, handle: SandboxHandle) -> None: + native = handle._native + if not native: + return + # Warm-pool sandboxes are owned by their SandboxClaim — deleting the + # claim recycles the adopted pod. Direct CRs are deleted outright. + claim = native.get("claim") + if claim: + helper = self._claim_helper + if helper is None: + from k8s_agent_sandbox.k8s_helper import K8sHelper + + helper = K8sHelper() + self._claim_helper = helper + try: + helper.delete_sandbox_claim(claim, native["namespace"]) + except self._k8s.exceptions.ApiException as exc: + if exc.status != 404: + raise + return + self._delete(native["name"], native["namespace"]) + + # -- internals --------------------------------------------------------- + + def _delete(self, name: str, namespace: str) -> None: + try: + self._custom.delete_namespaced_custom_object( + group=_SANDBOX_GROUP, + version=_SANDBOX_VERSION, + namespace=namespace, + plural=_SANDBOX_PLURAL, + name=name, + ) + except self._k8s.exceptions.ApiException as exc: + if exc.status != 404: + raise + + def _exec(self, handle: SandboxHandle, argv: list[str], *, timeout: int) -> ExecResult: + from kubernetes.stream import stream + + native = handle._native + resp = stream( + self._core.connect_get_namespaced_pod_exec, + native["name"], + native["namespace"], + container=_SANDBOX_CONTAINER, + command=argv, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + stdout_chunks: list[str] = [] + stderr_chunks: list[str] = [] + deadline = time.monotonic() + timeout + try: + while resp.is_open(): + if time.monotonic() > deadline: + resp.close() + raise TimeoutError(f"command timed out after {timeout}s") + resp.update(timeout=1) + if resp.peek_stdout(): + stdout_chunks.append(resp.read_stdout()) + if resp.peek_stderr(): + stderr_chunks.append(resp.read_stderr()) + rc = resp.returncode + finally: + resp.close() + return ExecResult( + stdout="".join(stdout_chunks), + stderr="".join(stderr_chunks), + exit_code=int(rc) if rc is not None else 0, + ) + + # --------------------------------------------------------------------------- # In-memory fake (tests + cluster-free smoke) # --------------------------------------------------------------------------- @@ -222,11 +540,12 @@ def _default_runner(command: str, files: dict[str, str]) -> ExecResult: def create_sandbox( self, *, - warmpool: str, + template: str, + warmpool: str | None = None, labels: dict[str, str] | None = None, ttl_seconds: int | None = None, ) -> SandboxHandle: - if warmpool in self._fail_warmpools: + if warmpool and warmpool in self._fail_warmpools: raise RuntimeError(f"warmpool {warmpool!r} has no ready sandboxes") sid = f"sbx-{uuid.uuid4().hex[:8]}" claim = f"sandbox-claim-{uuid.uuid4().hex[:8]}" @@ -252,7 +571,9 @@ def build_backend(config: Config) -> SandboxBackend: """Factory: pick the backend named by ``config.backend``.""" if config.backend == "fake": return FakeSandboxBackend() - return SdkSandboxBackend(config) + if config.backend == "sdk": + return SdkSandboxBackend(config) + return DirectSandboxBackend(config) def derive_command(config: Config, *, task: str, command: str) -> str: diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/config.py b/containers/sandbox-orchestrator/sandbox_orchestrator/config.py index c616951..a075e4b 100644 --- a/containers/sandbox-orchestrator/sandbox_orchestrator/config.py +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/config.py @@ -37,9 +37,13 @@ def _env_bool(name: str, default: bool) -> bool: VALID_CONNECTION_MODES = {"in-cluster", "local-tunnel", "gateway", "direct"} # Backends: -# sdk → real k8s-agent-sandbox SandboxClient (requires a cluster + SDK installed) -# fake → in-memory simulation (tests, local smoke without a cluster) -VALID_BACKENDS = {"sdk", "fake"} +# direct → create Sandbox CRs directly + drive them via pod exec. Matches the +# minimal agent-sandbox controller installed here (only the Sandbox +# CRD exists — no SandboxClaim/Template/WarmPool CRDs). +# sdk → upstream k8s-agent-sandbox SandboxClient (needs the claim/template/ +# warmpool CRDs, which this cluster does NOT have). +# fake → in-memory simulation (tests, local smoke without a cluster). +VALID_BACKENDS = {"direct", "sdk", "fake"} @dataclass(frozen=True) @@ -51,12 +55,33 @@ class Config: port: int = 8080 # --- Backend selection ----------------------------------------------- - backend: str = "sdk" + backend: str = "direct" connection_mode: str = "in-cluster" + # --- Direct backend: sandbox pod shape ------------------------------- + # Container image each sandbox pod runs. For real agent work this is the + # agent-pod image (ships Claude Code); for smoke tests any shell image works. + sandbox_image: str = "mcr.microsoft.com/cbl-mariner/busybox:2.0" + # The sandbox namespace enforces PodSecurity "restricted"; pods must run as + # a non-root UID. Mirrors the agent-pod's UID 1000. + sandbox_run_as_user: int = 1000 + # Resource envelope stamped onto the sandbox container. + sandbox_cpu_request: str = "250m" + sandbox_cpu_limit: str = "1000m" + sandbox_memory_request: str = "256Mi" + sandbox_memory_limit: str = "1Gi" + # --- Sandbox provisioning -------------------------------------------- # Namespace the SandboxClaim / SandboxWarmPool live in. sandbox_namespace: str = "agent-sandboxes" + # SandboxTemplate the "sdk" backend builds claims from (and the warm pool + # is stamped from). Required by the SDK's create_sandbox(template=...). + sandbox_template: str = "python-sandbox-template" + # When True, the "direct" backend provisions by creating a SandboxClaim that + # adopts a pre-warmed pod from the warm pool (fast, session-style), then + # drives it via pod exec. When False it cold-creates a bare Sandbox CR from + # the Config pod shape (the original cluster-minimal smoke path). + sandbox_use_warmpool: bool = True # Default warm pool to claim from when a request doesn't name one. default_warmpool: str = "python-sandbox-warmpool" # How long to wait for a claimed sandbox to report Ready. @@ -99,7 +124,7 @@ class Config: @classmethod def from_env(cls) -> "Config": - backend = os.getenv("ORCHESTRATOR_BACKEND", "sdk").strip().lower() + backend = os.getenv("ORCHESTRATOR_BACKEND", "direct").strip().lower() if backend not in VALID_BACKENDS: raise ValueError( f"ORCHESTRATOR_BACKEND must be one of {sorted(VALID_BACKENDS)}, " @@ -118,7 +143,15 @@ def from_env(cls) -> "Config": port=_env_int("ORCHESTRATOR_PORT", 8080), backend=backend, connection_mode=connection_mode, + sandbox_image=os.getenv("SANDBOX_IMAGE", "mcr.microsoft.com/cbl-mariner/busybox:2.0"), + sandbox_run_as_user=_env_int("SANDBOX_RUN_AS_USER", 1000), + sandbox_cpu_request=os.getenv("SANDBOX_CPU_REQUEST", "250m"), + sandbox_cpu_limit=os.getenv("SANDBOX_CPU_LIMIT", "1000m"), + sandbox_memory_request=os.getenv("SANDBOX_MEMORY_REQUEST", "256Mi"), + sandbox_memory_limit=os.getenv("SANDBOX_MEMORY_LIMIT", "1Gi"), sandbox_namespace=os.getenv("SANDBOX_NAMESPACE", "agent-sandboxes"), + sandbox_template=os.getenv("SANDBOX_TEMPLATE", "python-sandbox-template"), + sandbox_use_warmpool=_env_bool("SANDBOX_USE_WARMPOOL", True), default_warmpool=os.getenv("SANDBOX_WARMPOOL", "python-sandbox-warmpool"), sandbox_ready_timeout=_env_int("SANDBOX_READY_TIMEOUT", 180), sandbox_server_port=_env_int("SANDBOX_SERVER_PORT", 8888), diff --git a/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py b/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py index 56f8005..6e2d7a7 100644 --- a/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py +++ b/containers/sandbox-orchestrator/sandbox_orchestrator/manager.py @@ -135,6 +135,7 @@ def handle_request(self, req: SandboxRequest) -> SandboxRecord: self._set_state(record, RequestState.PROVISIONING) labels = self._sandbox_labels(req, record) handle = self._backend.create_sandbox( + template=self._config.sandbox_template, warmpool=warmpool, labels=labels, ttl_seconds=req.ttl_seconds if req.ttl_seconds is not None diff --git a/containers/session-router/CLAUDE.md b/containers/session-router/CLAUDE.md deleted file mode 100644 index 86ef1df..0000000 --- a/containers/session-router/CLAUDE.md +++ /dev/null @@ -1,69 +0,0 @@ -# CLAUDE.md — Session Router - -> Loaded when `claude` runs in `containers/session-router/`. - -## What this is - -A Go service (single binary, `main.go`, ~155 lines) that: - -1. Accepts `POST /sessions` with `{dev_id, project_id}`. -2. Looks up Redis for an existing warm session for that pair → returns it. -3. Otherwise, lists agent pods labeled `app=agent-pod,state=warm`, picks one, - **patches the pod's labels** to `state=bound, session-id=…, dev-id=…`, - binds the dev's workspace PVC, writes a session record to Redis (TTL = - idle timeout) and Cosmos (audit log). -4. Runs an idle reaper goroutine that scans Redis for expired sessions and - patches the corresponding pod to `state=cooldown` (which triggers the - pod's preStop scrub and graceful exit). - -## Why Go (not Python) - -- Native `client-go` for tight Kubernetes API integration. -- Single static binary in a distroless image — small attack surface. -- Goroutines for the reaper without async/await overhead. -- ~10ms p99 on the claim path under load (measured in dev). - -## Build & run - -```bash -go build ./... # quick compile check -go test ./... # (TODO — write tests) -docker build -t code-forge/session-router:dev . -``` - -## Environment variables - -| Var | Purpose | -|---|---| -| `REDIS_HOST`, `REDIS_PORT` | Session state cache | -| `COSMOS_ENDPOINT`, `COSMOS_DATABASE`, `COSMOS_CONTAINER` | Long-term session audit | -| `AGENT_POOL_NAMESPACE` | Where the warm pods live (`agent-pool`) | -| `MODEL_GATEWAY_URL` | Where to mint LiteLLM virtual keys | -| `IDLE_EVICT_SECONDS` | TTL on session keys → drives reaper cadence | -| `MAX_CONCURRENT_PER_DEV` | Quota cap | - -Auth to Kubernetes is **in-cluster** (`rest.InClusterConfig()`); auth to Cosmos -and Service Bus is **Workload Identity** via `DefaultAzureCredential` (TODO: -swap the Redis client for the Azure SDK Cosmos client + add the Service Bus -publisher). - -## Editing rules - -1. **Don't block in HTTP handlers.** The claim path is on the user's hot loop. Anything > 50ms goes to a goroutine. -2. **Patch pods atomically with strategic-merge.** Concurrent claims are guarded by `state=warm` → `state=bound` being a single label patch — if two routers race, the second sees `state=bound` and picks the next pod. -3. **Idempotent claims.** Re-claiming an existing session must return the same handle. The Redis lookup at step 2 covers this; never bypass it. -4. **Reaper must be conservative.** When in doubt, leave the session alive — a stuck pod is cheaper than a confused developer. - -## Open work (TODOs in `main.go`) - -- [ ] `budgetOK()` — wire to Cosmos / LiteLLM `/spend/total` endpoint -- [ ] `enqueuePending()` — push to Service Bus `agent-pod-claims` so KEDA scales the pool -- [ ] `bindWorkspace()` — patch the pod spec or use a CSI inline volume to mount the dev's PVC -- [ ] OpenTelemetry traces tagged with `session_id`, `dev_id` -- [ ] Tests (`httptest` + a fake clientset) - -## Pitfalls - -- **Pod state drift** — if a router crashes after `state=bound` but before writing Redis, the pod is "lost". A janitor cron (`docs/OPERATIONS.md`) reconciles labels with Redis every 5 min. -- **Cosmos throttling under burst** — bulk-claim on session start. Use the Cosmos bulk executor or fan out writes via Service Bus. -- **`kubectl exec`-based I/O is fragile** — long-lived connections through the API server hit timeouts. The longer-term plan is a per-pod HTTP shim that the client connects to directly via Service Mesh. diff --git a/containers/session-router/Dockerfile b/containers/session-router/Dockerfile deleted file mode 100644 index fd2a010..0000000 --- a/containers/session-router/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# ============================================================================= -# Session Router — claims/releases agent pods on behalf of developers. -# ============================================================================= -FROM golang:1.22-bookworm AS build -WORKDIR /src -COPY go.mod ./ -RUN go mod download || true -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/router ./... - -FROM gcr.io/distroless/static:nonroot -USER nonroot:nonroot -COPY --from=build /out/router /router -EXPOSE 8080 -ENTRYPOINT ["/router"] diff --git a/containers/session-router/go.mod b/containers/session-router/go.mod deleted file mode 100644 index 118ccd7..0000000 --- a/containers/session-router/go.mod +++ /dev/null @@ -1,51 +0,0 @@ -module github.com/code-forge/session-router - -go 1.22.0 - -require ( - github.com/redis/go-redis/v9 v9.7.0 - k8s.io/apimachinery v0.31.3 - k8s.io/client-go v0.31.3 -) - -require ( - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/x448/float16 v0.8.4 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.3.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.3 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect -) diff --git a/containers/session-router/go.sum b/containers/session-router/go.sum deleted file mode 100644 index 755413e..0000000 --- a/containers/session-router/go.sum +++ /dev/null @@ -1,162 +0,0 @@ -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= -github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= -github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.31.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8= -k8s.io/api v0.31.3/go.mod h1:UJrkIp9pnMOI9K2nlL6vwpxRzzEX5sWgn8kGQe92kCE= -k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4= -k8s.io/apimachinery v0.31.3/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4= -k8s.io/client-go v0.31.3/go.mod h1:2CgjPUTpv3fE5dNygAr2NcM8nhHzXvxB8KL5gYc3kJs= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/containers/session-router/main.go b/containers/session-router/main.go deleted file mode 100644 index f0930ad..0000000 --- a/containers/session-router/main.go +++ /dev/null @@ -1,155 +0,0 @@ -// session-router — stateless allocator. Sketch of the core claim/release loop. -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "strconv" - "time" - - "github.com/redis/go-redis/v9" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" -) - -type Session struct { - ID string `json:"id"` - DevID string `json:"dev_id"` - ProjectID string `json:"project_id"` - PodName string `json:"pod_name"` - NodeName string `json:"node_name"` - BoundAt time.Time `json:"bound_at"` - LastSeenAt time.Time `json:"last_seen_at"` -} - -type Router struct { - rdb *redis.Client - k8s *kubernetes.Clientset - idleSec int - maxConc int -} - -// POST /sessions body: {dev_id, project_id} -// Returns existing session if warm, else allocates a free pod. -func (r *Router) handleClaim(w http.ResponseWriter, req *http.Request) { - var body struct{ DevID, ProjectID string } - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { - http.Error(w, err.Error(), 400); return - } - ctx := req.Context() - key := fmt.Sprintf("sess:%s:%s", body.DevID, body.ProjectID) - - // 1. existing session? - if raw, err := r.rdb.Get(ctx, key).Result(); err == nil { - var s Session - _ = json.Unmarshal([]byte(raw), &s) - s.LastSeenAt = time.Now() - r.rdb.Set(ctx, key, mustJSON(s), time.Duration(r.idleSec)*time.Second) - writeJSON(w, s); return - } - - // 2. quota check (concurrent + budget) - if cnt, _ := r.rdb.SCard(ctx, "dev:"+body.DevID+":sessions").Result(); int(cnt) >= r.maxConc { - http.Error(w, "concurrent session limit", 429); return - } - if !r.budgetOK(ctx, body.DevID) { - http.Error(w, "daily budget exhausted", 402); return - } - - // 3. find a warm pod and bind it - pods, err := r.k8s.CoreV1().Pods("agent-pool").List(ctx, metav1.ListOptions{ - LabelSelector: "app=agent-pod,state=warm", - Limit: 10, - }) - if err != nil || len(pods.Items) == 0 { - // pool empty → enqueue and return 202 - r.enqueuePending(ctx, body.DevID, body.ProjectID) - http.Error(w, "pool empty, queued", 202); return - } - pod := pods.Items[0] - - // 4. patch labels: state=bound, dev-id, session-id, project-id - sessID := newID() - patch := fmt.Sprintf( - `{"metadata":{"labels":{"state":"bound","session-id":%q,"dev-id":%q,"project-id":%q}}}`, - sessID, body.DevID, body.ProjectID, - ) - if _, err := r.k8s.CoreV1().Pods("agent-pool"). - Patch(ctx, pod.Name, "application/strategic-merge-patch+json", []byte(patch), metav1.PatchOptions{}); err != nil { - http.Error(w, err.Error(), 500); return - } - - // 5. mount the dev's workspace PVC (separate call: bind PVC to pod via projected volume update) - r.bindWorkspace(ctx, pod.Name, body.DevID, body.ProjectID) - - s := Session{ - ID: sessID, DevID: body.DevID, ProjectID: body.ProjectID, - PodName: pod.Name, NodeName: pod.Spec.NodeName, - BoundAt: time.Now(), LastSeenAt: time.Now(), - } - r.rdb.Set(ctx, key, mustJSON(s), time.Duration(r.idleSec)*time.Second) - r.rdb.SAdd(ctx, "dev:"+body.DevID+":sessions", sessID) - writeJSON(w, s) -} - -// Idle reaper — runs as a goroutine, polls Redis for expired keys -// and patches pods state=bound -> state=cooldown -> deletes (replaced by replicaset) -func (r *Router) reapIdle(ctx context.Context) { - t := time.NewTicker(30 * time.Second) - for range t.C { - iter := r.rdb.Scan(ctx, 0, "sess:*", 100).Iterator() - for iter.Next(ctx) { - key := iter.Val() - ttl, _ := r.rdb.TTL(ctx, key).Result() - if ttl < 30*time.Second && ttl > 0 { - continue - } - var s Session - raw, _ := r.rdb.Get(ctx, key).Result() - _ = json.Unmarshal([]byte(raw), &s) - r.releasePod(ctx, s) - r.rdb.Del(ctx, key) - r.rdb.SRem(ctx, "dev:"+s.DevID+":sessions", s.ID) - } - } -} - -func (r *Router) releasePod(ctx context.Context, s Session) { - // Mark state=cooldown — the kubelet preStop hook scrubs /workspace, then exits. - // ReplicaSet replaces the pod with a fresh state=warm one. - patch := `{"metadata":{"labels":{"state":"cooldown"}}}` - _, _ = r.k8s.CoreV1().Pods("agent-pool"). - Patch(ctx, s.PodName, "application/strategic-merge-patch+json", []byte(patch), metav1.PatchOptions{}) - _ = r.k8s.CoreV1().Pods("agent-pool").Delete(ctx, s.PodName, metav1.DeleteOptions{}) -} - -// --- helpers (stubs) --- -func (r *Router) budgetOK(ctx context.Context, devID string) bool { return true } -func (r *Router) enqueuePending(ctx context.Context, dev, proj string) {} -func (r *Router) bindWorkspace(ctx context.Context, pod, dev, proj string) {} -func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } -func mustJSON(v interface{}) string { b, _ := json.Marshal(v); return string(b) } -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) -} - -func main() { - idle, _ := strconv.Atoi(os.Getenv("IDLE_EVICT_SECONDS")) - maxc, _ := strconv.Atoi(os.Getenv("MAX_CONCURRENT_PER_DEV")) - cfg, _ := rest.InClusterConfig() - cs, _ := kubernetes.NewForConfig(cfg) - r := &Router{ - rdb: redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")}), - k8s: cs, idleSec: idle, maxConc: maxc, - } - go r.reapIdle(context.Background()) - http.HandleFunc("/sessions", r.handleClaim) - http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }) - fmt.Println("session-router listening on :8080") - http.ListenAndServe(":8080", nil) -} diff --git a/deploy/agent-pod-pool.yaml b/deploy/agent-pod-pool.yaml deleted file mode 100644 index f064c06..0000000 --- a/deploy/agent-pod-pool.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# ============================================================================= -# Warm Agent Pod Pool — stateless, scaled by KEDA on Service Bus depth. -# Pods start in state=warm; router patches state=bound when claimed. -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: agent-pool - labels: - pod-security.kubernetes.io/enforce: restricted ---- -apiVersion: v1 -kind: ResourceQuota -metadata: { name: pool-quota, namespace: agent-pool } -spec: - hard: - pods: "200" - requests.cpu: "200" - requests.memory: 400Gi - persistentvolumeclaims: "50" # mounted on demand, not persistent ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent-pod - namespace: agent-pool - labels: { app: agent-pod } -spec: - replicas: 80 # baseline warm pool — KEDA overrides - selector: { matchLabels: { app: agent-pod } } - template: - metadata: - labels: - app: agent-pod - state: warm # router flips to "bound" on allocation - spec: - serviceAccountName: agent-pod - nodeSelector: { agentpool: spotagents } - tolerations: - - key: kubernetes.azure.com/scalesetpriority - operator: Equal - value: spot - effect: NoSchedule - terminationGracePeriodSeconds: 30 - containers: - - name: agent - image: acrtheclouds.azurecr.io/code-forge/agent:1.0.0 - env: - - name: CLAUDE_CODE_USE_FOUNDRY - value: "1" - - name: ANTHROPIC_FOUNDRY_BASE_URL - value: "http://model-gateway.platform.svc.cluster.local" # via gateway, not direct - - name: SESSION_ID - valueFrom: { fieldRef: { fieldPath: metadata.labels['session-id'] } } - - name: DEV_ID - valueFrom: { fieldRef: { fieldPath: metadata.labels['dev-id'] } } - resources: - requests: { cpu: 1, memory: 2Gi } - limits: { cpu: 4, memory: 8Gi } - volumeMounts: - - name: workspace - mountPath: /workspace - - name: secrets - mountPath: /secrets - readOnly: true - # Idle reaper signal: router patches annotation, pod self-exits cleanly - lifecycle: - preStop: - exec: - command: ["/bin/sh","-c","/app/scrub-workspace.sh"] - volumes: - - name: workspace - persistentVolumeClaim: - claimName: workspace-placeholder # rebound by router on claim - - name: secrets - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: per-dev-secrets ---- -# KEDA: scale pool on pending-session queue depth -apiVersion: keda.sh/v1alpha1 -kind: ScaledObject -metadata: - name: agent-pod-scaler - namespace: agent-pool -spec: - scaleTargetRef: { name: agent-pod } - minReplicaCount: 40 # always warm headroom - maxReplicaCount: 400 - pollingInterval: 10 - cooldownPeriod: 120 - triggers: - - type: azure-servicebus - metadata: - queueName: pending-sessions - messageCount: "5" # 1 extra pod per 5 queued sessions - authenticationRef: { name: sb-auth } ---- -# NetworkPolicy: a session pod can ONLY reach the model gateway, -# its own dev's KV secrets (via CSI), and blob via private endpoint. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: { name: agent-pod-egress, namespace: agent-pool } -spec: - podSelector: { matchLabels: { app: agent-pod } } - policyTypes: [Egress] - egress: - - to: - - namespaceSelector: { matchLabels: { name: platform } } - podSelector: { matchLabels: { app: model-gateway } } - ports: [{ port: 80 }] - - to: - - ipBlock: { cidr: 10.10.0.0/16 } # private endpoint subnet (KV + blob) - ports: [{ port: 443 }] - - to: [] - ports: [{ port: 53, protocol: UDP }] # DNS diff --git a/deploy/router/main.go b/deploy/router/main.go deleted file mode 100644 index f0930ad..0000000 --- a/deploy/router/main.go +++ /dev/null @@ -1,155 +0,0 @@ -// session-router — stateless allocator. Sketch of the core claim/release loop. -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "strconv" - "time" - - "github.com/redis/go-redis/v9" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" -) - -type Session struct { - ID string `json:"id"` - DevID string `json:"dev_id"` - ProjectID string `json:"project_id"` - PodName string `json:"pod_name"` - NodeName string `json:"node_name"` - BoundAt time.Time `json:"bound_at"` - LastSeenAt time.Time `json:"last_seen_at"` -} - -type Router struct { - rdb *redis.Client - k8s *kubernetes.Clientset - idleSec int - maxConc int -} - -// POST /sessions body: {dev_id, project_id} -// Returns existing session if warm, else allocates a free pod. -func (r *Router) handleClaim(w http.ResponseWriter, req *http.Request) { - var body struct{ DevID, ProjectID string } - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { - http.Error(w, err.Error(), 400); return - } - ctx := req.Context() - key := fmt.Sprintf("sess:%s:%s", body.DevID, body.ProjectID) - - // 1. existing session? - if raw, err := r.rdb.Get(ctx, key).Result(); err == nil { - var s Session - _ = json.Unmarshal([]byte(raw), &s) - s.LastSeenAt = time.Now() - r.rdb.Set(ctx, key, mustJSON(s), time.Duration(r.idleSec)*time.Second) - writeJSON(w, s); return - } - - // 2. quota check (concurrent + budget) - if cnt, _ := r.rdb.SCard(ctx, "dev:"+body.DevID+":sessions").Result(); int(cnt) >= r.maxConc { - http.Error(w, "concurrent session limit", 429); return - } - if !r.budgetOK(ctx, body.DevID) { - http.Error(w, "daily budget exhausted", 402); return - } - - // 3. find a warm pod and bind it - pods, err := r.k8s.CoreV1().Pods("agent-pool").List(ctx, metav1.ListOptions{ - LabelSelector: "app=agent-pod,state=warm", - Limit: 10, - }) - if err != nil || len(pods.Items) == 0 { - // pool empty → enqueue and return 202 - r.enqueuePending(ctx, body.DevID, body.ProjectID) - http.Error(w, "pool empty, queued", 202); return - } - pod := pods.Items[0] - - // 4. patch labels: state=bound, dev-id, session-id, project-id - sessID := newID() - patch := fmt.Sprintf( - `{"metadata":{"labels":{"state":"bound","session-id":%q,"dev-id":%q,"project-id":%q}}}`, - sessID, body.DevID, body.ProjectID, - ) - if _, err := r.k8s.CoreV1().Pods("agent-pool"). - Patch(ctx, pod.Name, "application/strategic-merge-patch+json", []byte(patch), metav1.PatchOptions{}); err != nil { - http.Error(w, err.Error(), 500); return - } - - // 5. mount the dev's workspace PVC (separate call: bind PVC to pod via projected volume update) - r.bindWorkspace(ctx, pod.Name, body.DevID, body.ProjectID) - - s := Session{ - ID: sessID, DevID: body.DevID, ProjectID: body.ProjectID, - PodName: pod.Name, NodeName: pod.Spec.NodeName, - BoundAt: time.Now(), LastSeenAt: time.Now(), - } - r.rdb.Set(ctx, key, mustJSON(s), time.Duration(r.idleSec)*time.Second) - r.rdb.SAdd(ctx, "dev:"+body.DevID+":sessions", sessID) - writeJSON(w, s) -} - -// Idle reaper — runs as a goroutine, polls Redis for expired keys -// and patches pods state=bound -> state=cooldown -> deletes (replaced by replicaset) -func (r *Router) reapIdle(ctx context.Context) { - t := time.NewTicker(30 * time.Second) - for range t.C { - iter := r.rdb.Scan(ctx, 0, "sess:*", 100).Iterator() - for iter.Next(ctx) { - key := iter.Val() - ttl, _ := r.rdb.TTL(ctx, key).Result() - if ttl < 30*time.Second && ttl > 0 { - continue - } - var s Session - raw, _ := r.rdb.Get(ctx, key).Result() - _ = json.Unmarshal([]byte(raw), &s) - r.releasePod(ctx, s) - r.rdb.Del(ctx, key) - r.rdb.SRem(ctx, "dev:"+s.DevID+":sessions", s.ID) - } - } -} - -func (r *Router) releasePod(ctx context.Context, s Session) { - // Mark state=cooldown — the kubelet preStop hook scrubs /workspace, then exits. - // ReplicaSet replaces the pod with a fresh state=warm one. - patch := `{"metadata":{"labels":{"state":"cooldown"}}}` - _, _ = r.k8s.CoreV1().Pods("agent-pool"). - Patch(ctx, s.PodName, "application/strategic-merge-patch+json", []byte(patch), metav1.PatchOptions{}) - _ = r.k8s.CoreV1().Pods("agent-pool").Delete(ctx, s.PodName, metav1.DeleteOptions{}) -} - -// --- helpers (stubs) --- -func (r *Router) budgetOK(ctx context.Context, devID string) bool { return true } -func (r *Router) enqueuePending(ctx context.Context, dev, proj string) {} -func (r *Router) bindWorkspace(ctx context.Context, pod, dev, proj string) {} -func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } -func mustJSON(v interface{}) string { b, _ := json.Marshal(v); return string(b) } -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) -} - -func main() { - idle, _ := strconv.Atoi(os.Getenv("IDLE_EVICT_SECONDS")) - maxc, _ := strconv.Atoi(os.Getenv("MAX_CONCURRENT_PER_DEV")) - cfg, _ := rest.InClusterConfig() - cs, _ := kubernetes.NewForConfig(cfg) - r := &Router{ - rdb: redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")}), - k8s: cs, idleSec: idle, maxConc: maxc, - } - go r.reapIdle(context.Background()) - http.HandleFunc("/sessions", r.handleClaim) - http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }) - fmt.Println("session-router listening on :8080") - http.ListenAndServe(":8080", nil) -} diff --git a/deploy/session-router.yaml b/deploy/session-router.yaml deleted file mode 100644 index f5e6283..0000000 --- a/deploy/session-router.yaml +++ /dev/null @@ -1,92 +0,0 @@ -# ============================================================================= -# Session Router — stateless service that maps (dev,project) -> warm pod -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: session-control - labels: - pod-security.kubernetes.io/enforce: restricted ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: router-config - namespace: session-control -data: - IDLE_EVICT_SECONDS: "900" # 15 min - MAX_CONCURRENT_PER_DEV: "3" - DAILY_BUDGET_USD_PER_DEV: "50" - POOL_NAMESPACE: "agent-pool" - POD_LABEL_SELECTOR: "app=agent-pod,state=warm" ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: session-router - namespace: session-control -spec: - replicas: 3 - selector: { matchLabels: { app: session-router } } - template: - metadata: - labels: { app: session-router } - spec: - serviceAccountName: session-router - containers: - - name: router - image: acrtheclouds.azurecr.io/code-forge/session-router:1.0.0 - ports: [{ containerPort: 8080 }] - envFrom: - - configMapRef: { name: router-config } - env: - - name: REDIS_URL - valueFrom: { secretKeyRef: { name: redis-conn, key: url } } - - name: SERVICE_BUS_CONN - valueFrom: { secretKeyRef: { name: sb-conn, key: connection } } - resources: - requests: { cpu: 200m, memory: 256Mi } - limits: { cpu: 1, memory: 512Mi } - readinessProbe: { httpGet: { path: /healthz, port: 8080 } } ---- -apiVersion: v1 -kind: Service -metadata: - name: session-router - namespace: session-control -spec: - selector: { app: session-router } - ports: [{ port: 80, targetPort: 8080 }] ---- -# Workload identity to talk to KV + AKS API -apiVersion: v1 -kind: ServiceAccount -metadata: - name: session-router - namespace: session-control - annotations: - azure.workload.identity/client-id: "REPLACE-WITH-UAMI-CLIENT-ID" ---- -# RBAC: router can label/patch agent pods to bind/release them -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: { name: pod-allocator } -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch","patch"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get","create","delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: { name: session-router-allocator } -subjects: - - kind: ServiceAccount - name: session-router - namespace: session-control -roleRef: - kind: ClusterRole - name: pod-allocator - apiGroup: rbac.authorization.k8s.io From 4520eae458b3bf691fa74426ac4e580e564b2424 Mon Sep 17 00:00:00 2001 From: Michael Liav Date: Tue, 9 Jun 2026 11:40:28 +0300 Subject: [PATCH 5/8] docs: align deep-dives with sandbox-orchestrator migration --- docs/ARCHITECTURE.md | 92 +++++++++++++++++++------------------------- docs/DEVELOPMENT.md | 33 ++++++++-------- docs/ONBOARDING.md | 6 +-- docs/OPERATIONS.md | 57 ++++++++++++++------------- docs/SECURITY.md | 44 ++++++++++----------- 5 files changed, 112 insertions(+), 120 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 517ac2b..cc5cc31 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,26 +23,24 @@ │ ┌──────────────────────────────┐ │ │ │ Namespace: session-control │ │ │ │ ┌───────────────────────┐ │ │ - │ │ │ session-router (Go) │ │ │ - │ │ │ HTTPScaledObject (KEDA│ │ │ - │ │ │ HTTP Add-on, RPS) │ │ │ + │ │ │ sandbox-orchestrator │ │ │ + │ │ │ (FastAPI, Python) │ │ │ + │ │ │ claims + apiserver exec│ │ │ │ │ └────┬────────┬─────────┘ │ │ │ └───────│────────│─────────────┘ │ - │ │ │ patch labels │ - │ bind │ │ │ + │ │ │ SandboxClaim │ + │ claim │ │ (adopt warm) │ │ ▼ ▼ │ │ ┌──────────────────────────────┐ │ - │ │ Namespace: agent-pool │ │ + │ │ Namespace: agent-sandboxes │ │ │ │ PSA: restricted │ │ + │ │ SandboxWarmPool (pre-warmed)│ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ - │ │ │ pod │ │ pod │ │ pod │ │ │ - │ │ │ warm │ │bound │ │ warm │ │ │ + │ │ │ sbox │ │ sbox │ │ sbox │ │ │ + │ │ │ warm │ │ used │ │ warm │ │ │ │ │ │claude│ │claude│ │claude│ │ │ │ │ └──┬───┘ └──┬───┘ └──┬───┘ │ │ │ │ └────────┴────────┘ │ │ - │ │ │ │ │ - │ │ ScaledObject (KEDA, │ │ - │ │ Service Bus depth) │ │ │ └───────────│──────────────────┘ │ │ │ │ │ ▼ /anthropic │ @@ -52,11 +50,8 @@ │ │ │ model-gateway (LiteLLM)│ │ │ │ │ │ + AAD-token sidecar │ │ │ │ │ └─────────┬──────────────┘ │ │ - │ │ ┌─────────┴──────┐ │ │ - │ │ │ Redis (state) │ │ │ - │ │ └────────────────┘ │ │ - │ └─────────────│────────────────┘ │ - └────────────────│───────────────────┘ + │ └────────────│────────────────┘ │ + └───────────────│───────────────────┘ │ HTTPS + AAD bearer ▼ ┌──────────────────────────────────┐ @@ -66,8 +61,9 @@ │ - claude-haiku-4-5 deployment │ └──────────────────────────────────┘ - Side services: Cosmos DB (audit), Service Bus (KEDA queue), Key Vault - (LiteLLM master key), Azure Files (per-dev workspace PVCs), ACR. + Provided by the agent-sandbox controller (agent-sandbox-system namespace): + the SandboxClaim / SandboxTemplate / SandboxWarmPool CRDs. + Side services: Key Vault (LiteLLM master key), ACR, optional per-dev PVCs. ``` ## Request lifecycle in detail @@ -75,43 +71,35 @@ ### 1. Claim ``` -client → POST https://api.codeforge.example.com/sessions - { dev_id: "alice", project_id: "checkout-svc" } +client → POST https://api.codeforge.example.com/v1/sandboxes + { dev_id: "alice", command: "…" } # or a free-text task ``` -Front Door → router. Router: +Front Door → orchestrator. The orchestrator: -1. `GET sess:alice:checkout-svc` from Redis. -2. **Hit** → bump TTL, return `{pod_name, exec_url}`. Done. -3. **Miss** → check `MAX_CONCURRENT_PER_DEV` (Redis SCard). -4. **Miss** → `kubectl get pods -n agent-pool -l app=agent-pod,state=warm --limit=10`. -5. Pick first; **strategic-merge patch** labels: `state=bound, dev-id=alice, session-id=, project-id=checkout-svc`. -6. Bind workspace: patch the pod to mount Azure Files PVC `workspace-alice-checkout-svc`. -7. Mint a virtual key in LiteLLM: `POST /key/generate` with `models=[claude-opus,…]`, `max_budget=50`, `tpm=200000`, `rpm=200`. LiteLLM returns `sk-…`. -8. Write that key to a per-pod K8s `Secret` named `agent-virtual-key-`; the pod's env reads it via `secretKeyRef` (already wired in `_helpers.tpl`). -9. `SET sess:alice:checkout-svc {pod_name, …}` with TTL = idle timeout. -10. `INSERT` audit row into Cosmos. -11. Return. +1. Admission-checks `MAX_CONCURRENT_TOTAL` and `MAX_CONCURRENT_PER_DEV`; over either → HTTP 429. +2. Creates a `SandboxClaim` (`cf-claim-`) referencing the configured `SandboxTemplate` + `SandboxWarmPool`, with a controller-side TTL (`shutdownTime` / `shutdownPolicy: Delete`) as a safety net. +3. The agent-sandbox controller **adopts a pre-warmed pod** from the `SandboxWarmPool` and binds it to the claim (sub-2s). The claim's `status.sandbox.name` carries the adopted sandbox's name (which differs from the claim name). +4. The orchestrator resolves that sandbox name, waits for Ready (first podIP), and stages any request `files` into `/workspace` via the apiserver exec stream. +5. It runs the command inside the sandbox over the same exec stream and collects stdout/stderr/exit code. ### 2. Use -The pod's Claude Code now has a virtual key, hits the in-cluster gateway, which authenticates to Foundry via AAD, gets a streaming completion, decrements the dev's budget in LiteLLM's Postgres, returns to Claude Code. +Inside the sandbox, Claude Code is configured for Foundry (`CLAUDE_CODE_USE_FOUNDRY=1`, `ANTHROPIC_FOUNDRY_BASE_URL=http://model-gateway.platform.svc.cluster.local/anthropic`, pinned model names). It calls the in-cluster **gateway**, which authenticates to Foundry via a federated AAD token, applies the per-dev budget / RPM caps, forwards the request, and logs cost. The sandbox never sees Foundry or an API key directly. ### 3. Release -Three triggers: - -- **Idle** — pod's `agent-entrypoint` watchdog sees no activity for 15 min → calls `agent-shutdown` → exits → ReplicaSet replaces. -- **Spot eviction** — Service Bus message published by AKS spot-eviction handler. Router picks it up, marks the session `migrating`, claims a new warm pod, re-mounts the same PVC, transparently resumes (lossy if mid-stream — TODO: resume-on-reconnect via Claude Code session resume). -- **Explicit logout** — `DELETE /sessions/` from the client. Router patches `state=cooldown`, deletes the K8s secret, evicts from Redis. +- **Completion** — when the command finishes, the orchestrator deletes the `SandboxClaim`. The warm pool controller self-heals back to its target `readyReplicas`. +- **TTL** — if the orchestrator crashes mid-request, the claim's `shutdownTime` lets the controller reap the sandbox without manual cleanup. +- **Pool refresh** — each sandbox is single-use; the read-only root FS + emptyDir `/workspace` mean there is no cross-session residue to scrub. ## Why these tech choices | Choice | Why | What we considered | |---|---|---| -| AKS (vs. ACI / Container Apps) | Full PSA, NetworkPolicies, KEDA HTTP Add-on, custom CSI drivers | Container Apps doesn't expose NetworkPolicy; ACI doesn't pool | -| KEDA HTTP Add-on for the router | Scale-to-zero off-hours, RPS-based, request buffering during cold-start | Plain HPA on CPU lags by minutes | -| KEDA Service Bus for agent pool | Decouples claim demand from pool size; queue acts as buffer for burst | HPA-on-Redis would work but Service Bus is the standard pattern with auth via MI | +| AKS (vs. ACI / Container Apps) | Full PSA, NetworkPolicies, custom CSI drivers, and the agent-sandbox CRDs | Container Apps doesn't expose NetworkPolicy; ACI doesn't pool | +| agent-sandbox `SandboxWarmPool` (vs. a hand-rolled warm Deployment + KEDA) | Pre-warmed pods + claim adoption give sub-2s allocation with a controller that owns lifecycle; no label-patching state machine | A KEDA-scaled Deployment with `state=warm/bound` labels (our original v1 — retired) | +| apiserver `exec` stream for I/O (vs. an in-pod HTTP server) | The agent-pod image runs Claude Code, not a runtime HTTP server; exec needs no extra surface or NetworkPolicy ingress | The agent-sandbox SDK's HTTP transport — needs a runtime server in the image we don't ship | | LiteLLM (vs. APIM / Traefik) | Native LLM features: virtual keys, budgets, prompt-cache routing, model fallback | APIM lacks LLM-native budget; Traefik isn't aware of token semantics | | Workload Identity (vs. AAD Pod Identity / static keys) | OIDC-federated, no secret on disk, per-workload MI | Pod Identity is deprecated; static keys are an audit nightmare | | Bicep (vs. Terraform) | First-party Azure, `what-if` is excellent, no state file to manage | Terraform if multi-cloud — we're not | @@ -120,27 +108,27 @@ Three triggers: ## Capacity model -- **Warm pool baseline**: 80 pods. Each pod = 1 active session. -- **Burst max**: 400 pods (KEDA `maxReplicas`). -- **Pod size**: `1.5–4 CPU`, `3–8 GiB`. Spot-priced D8s_v5 ≈ $0.08/hr → ~$58/mo per pod. -- **Steady-state cost**: 80 pods × $58 + cluster overhead + Foundry tokens. Estimate: $8–12k/mo for ~150 active developers (2–3 sessions per dev per day, 90-min average). -- **Idle scrub**: 15 min. Tunable via `agentPod.idleTimeoutSeconds`. +- **Warm pool baseline**: `sandboxOrchestrator.sandbox.warmpoolReplicas` pre-warmed sandboxes kept Ready by the controller. Each adopted sandbox serves one request, then is torn down. +- **Admission caps**: `concurrency.maxTotal` (cluster-wide) and `concurrency.maxPerDev` bound in-flight sandboxes; exceeding either returns HTTP 429. +- **Sandbox size**: governed by `sandboxOrchestrator.sandbox.resources` (default `250m–1 CPU`, `256Mi–1Gi`). Tune up for heavier Claude Code workloads. +- **Scaling knob**: raise `warmpoolReplicas` for deeper burst headroom (more idle pods, faster claims) and `maxTotal` for higher concurrency ceilings. ## Failure modes & blast radius | Failure | Blast radius | Mitigation | |---|---|---| -| Router crash | New claims fail until pod restarts (~10s). Existing sessions unaffected (they talk to pods directly) | 3+ replicas behind a Service; KEDA HTTP Add-on absorbs the request burst | +| Orchestrator crash | New requests fail until the pod restarts (~10s). In-flight sandboxes keep running; their claims are reaped by TTL | 2+ replicas behind a Service; requests are stateless and retryable | +| agent-sandbox controller down | No new claims can be adopted/provisioned; existing sandboxes unaffected | Controller runs with leader-election; restart is fast and stateless | | Gateway crash | All in-flight Claude Code requests fail | 2+ replicas; LiteLLM auto-failover between Foundry deployments | | Foundry outage | Same as above | Multi-region Foundry deployment + LiteLLM `fallback_models` config | -| Redis crash | Session state lost — all dev sessions get a new pod on next message | Azure Cache for Redis with replication; Cosmos as durable backup | -| Spot eviction storm | Many pods evicted at once → claim queue backs up | KEDA scales pool from on-demand fallback; spot diversification across SKUs | +| Warm pool exhausted | Claims fall back to a cold provision from the `SandboxTemplate` (slower, still works) | Raise `warmpoolReplicas`; the controller refills the pool continuously | +| Sandbox pod eviction | One in-flight request fails | Request is retryable; the orchestrator deletes the claim and the pool self-heals | | AAD token refresh failure | Gateway returns 401 to all agents | Sidecar logs; if it can't refresh for 50 min, alert fires; gateway keeps retrying | ## Roadmap -- [ ] **HTTP shim per pod** — replace `kubectl exec` I/O channel with a pod-local HTTP shim + Service Mesh; eliminates API server proxy hops. -- [ ] **Resume-on-reconnect** — Claude Code's session resume + idempotency tokens so spot eviction is invisible. +- [ ] **Session affinity / resume** — let a dev reattach to a still-running sandbox for multi-turn work instead of one-shot claims. +- [ ] **Per-request virtual keys** — have the orchestrator mint a short-lived LiteLLM key per claim for finer-grained budget attribution. - [ ] **Multi-region active-active** — pair of clusters in westus3 + eastus2; Front Door routes by latency. - [ ] **Confidential Containers** — swap PSA-restricted for AKS Confidential Containers when GA on the SKU we use; isolation upgrade for sensitive customers. -- [ ] **OpenTelemetry traces** end-to-end with `session_id` and `dev_id` propagated. +- [ ] **OpenTelemetry traces** end-to-end with `request_id` and `dev_id` propagated. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2456695..d803775 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -18,18 +18,18 @@ git commit -am "feat: " gh pr create --fill ``` -CI runs: `helm lint`, `helm template`, `go build` (router), Bicep `what-if`, +CI runs: `helm lint`, `helm template`, `pytest` (orchestrator), Bicep `what-if`, hadolint, and trivy on the resulting images. Green CI + 1 review = mergeable. ## Where to make each kind of change | You want to | Edit | |---|---| -| Bump warm-pool size | `charts/code-forge/values.yaml` `agentPod.replicas` | +| Bump warm-pool size | `charts/code-forge/values.yaml` `sandboxOrchestrator.sandbox.warmpoolReplicas` | | Add a model | `values.yaml` `global.foundry.models` + LiteLLM ConfigMap | -| Add an env var to agent pods | `_helpers.tpl` (`code-forge.claudeCodeFoundryEnv`) | +| Add an env var to agent sandboxes | `_helpers.tpl` (`code-forge.claudeCodeFoundryEnv`) | | Tighten a network rule | `templates/50-network-policies.yaml` | -| Change router behavior | `containers/session-router/main.go` | +| Change orchestrator behavior | `containers/sandbox-orchestrator/sandbox_orchestrator/` | | Add an Azure service | `infra/modules/.bicep` + reference from `main.bicep` | | Add a new doc | `docs/.md` + link from root `CLAUDE.md` | @@ -53,14 +53,15 @@ docker run --rm -it code-forge/agent-pod:dev claude --version code containers/agent-pod ``` -### 3. Session-router changes +### 3. Sandbox-orchestrator changes ```bash -cd containers/session-router -go build ./... -go test ./... -# Run locally against fake redis + fake k8s: -KUBECONFIG=/tmp/fake REDIS_URL=localhost:6379 ./session-router +cd containers/sandbox-orchestrator +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +python -m pytest -q +# Run locally against a kubeconfig (uses your current context): +uvicorn sandbox_orchestrator.api:app --reload --port 8080 ``` ### 4. Bicep changes @@ -92,15 +93,15 @@ Try: > /init — refresh CLAUDE.md if you've reorganized > /commands — list project-specific slash commands -> Refactor the router idle reaper to use a wait group. -> Find every place we hardcode 'agent-pool' and route it through values.yaml. +> Refactor the orchestrator idle reaper to use an async task group. +> Find every place we hardcode 'agent-sandboxes' and route it through values.yaml. The slash commands in `.claude/commands/` are tuned for the most common chores: - `/render-chart` — `helm template` with sane defaults - `/lint-everything` — runs all linters in parallel - `/new-doc ` — scaffold a new doc page with our format -- `/build-images` — Docker build all three images +- `/build-images` — Docker build all three images (agent-pod, sandbox-orchestrator, model-gateway) - `/security-review` — opens `docs/SECURITY.md` and asks Claude to red-team a change ## Style conventions @@ -118,7 +119,7 @@ The slash commands in `.claude/commands/` are tuned for the most common chores: | Workflow | Trigger | Steps | |---|---|---| | `chart-ci.yaml` | PR touching `charts/**` | `helm lint`, `helm template`, kubeconform | -| `router-ci.yaml` | PR touching `containers/session-router/**` | `go build`, `go test`, `golangci-lint` | +| `orchestrator-ci.yaml` | PR touching `containers/sandbox-orchestrator/**` | `pytest`, `ruff`, `mypy` | | `image-ci.yaml` | PR touching `containers/**` | `hadolint`, `docker build`, `trivy` scan | | `bicep-ci.yaml` | PR touching `infra/**` | `bicep build`, `az deployment what-if` against a sandbox sub | | `release.yaml` | Tag push | Build + push images to ACR, helm package, helm push | @@ -126,6 +127,6 @@ The slash commands in `.claude/commands/` are tuned for the most common chores: ## Testing strategy - **Helm**: `helm template | kubeconform` covers schema. Snapshot-test the rendered output for important diffs. -- **Router**: unit tests with `httptest` + `client-go/testing.NewSimpleClientset()` + `miniredis`. +- **Orchestrator**: unit tests with `pytest` + a fake kube backend; admission/concurrency caps and claim lifecycle are covered in `containers/sandbox-orchestrator/tests/`. - **Gateway**: integration test with a fake Foundry-like server that returns canned Anthropic responses. -- **End-to-end**: spin up a kind cluster + install the chart against a dev Foundry resource. Run a synthetic claim → message → release loop. +- **End-to-end**: spin up a kind cluster + install the agent-sandbox CRDs/controller + the chart against a dev Foundry resource. Run a synthetic provision → command → teardown loop. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index ec10c9d..fe38c48 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -28,7 +28,7 @@ Read `CLAUDE.md` at the repo root for the full context. Then come back here. | `kubectl` ≥ 1.30 | Cluster ops | `brew install kubectl` | | `helm` ≥ 3.14 | Deploy the chart | `brew install helm` | | `bicep` | IaC | `az bicep install` | -| `go` ≥ 1.22 | Session router | `brew install go` | +| `python` ≥ 3.11 | Sandbox orchestrator | `brew install python` | | `claude` | The CLI itself, for local dev | `npm install -g @anthropic-ai/claude-code` | | VS Code + Dev Containers extension | Open `containers/agent-pod/.devcontainer` for prod-parity local dev | Marketplace | @@ -124,10 +124,10 @@ Tag Roey or Michael for review. Merge bar: green CI + one review. ## 8 · Things that surprise people -- **Pods are completely cattle.** Don't `kubectl exec` into one and `vim` config — your changes are gone in 15 min. +- **Sandboxes are completely cattle.** Don't `kubectl exec` into one and `vim` config — it's single-use and gone the moment your request finishes. - **`api.anthropic.com` doesn't appear in our network policies.** Agents talk to the *gateway*, not Anthropic. The gateway then talks to *Foundry*, not Anthropic. - **Static API keys will fail review.** Workload Identity, every time. If you can't figure out how to wire it, ask — don't paper over with a key. - **`pod-security.kubernetes.io/enforce: restricted`** rejects images that run as root. If a build of yours pods-pending into oblivion, that's usually why. -- **Spot nodes evict.** Don't write code that assumes a pod lives forever. Anything important goes in Redis / Cosmos / a PVC. +- **The warm pool is a CRD, not a Deployment.** Sizing lives in `sandboxOrchestrator.sandbox.warmpoolReplicas`; editing the `SandboxTemplate` won't recycle live warm pods (delete them by name to re-stamp). Welcome aboard. 🛠️ diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 21a422c..1769fcc 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -44,7 +44,7 @@ az term show --publisher anthropic --product anthropic-claude-foundry --plan cla - **Pager**: Code Forge SRE rotation (PagerDuty service `code-forge-prod`). - **Sev mapping**: - - **Sev1**: > 25% of devs can't claim a session, OR Foundry budget exceeded by 50% in 1h, OR data exfiltration suspected. + - **Sev1**: > 25% of devs can't get a sandbox, OR Foundry budget exceeded by 50% in 1h, OR data exfiltration suspected. - **Sev2**: Single-digit-percent error rate, single component down with redundancy intact. - **Sev3**: Cosmetic, no user impact. @@ -57,8 +57,9 @@ kubectl top nodes kubectl get events -A --sort-by='.lastTimestamp' | tail -50 # Code Forge specifics -kubectl -n session-control logs deploy/session-router --tail=200 -kubectl -n agent-pool get pods -l app=agent-pod -L state -L dev-id +kubectl -n session-control logs deploy/sandbox-orchestrator --tail=200 +kubectl -n agent-sandboxes get sandboxwarmpool,sandboxes,sandboxclaims +kubectl -n agent-sandbox-system logs deploy/agent-sandbox-controller --tail=100 kubectl -n platform logs deploy/model-gateway --tail=200 | grep -E '(refresh-aad|ERROR|429|401)' # Foundry budget @@ -68,12 +69,13 @@ curl -H "x-litellm-api-key: $LITELLM_MASTER_KEY" \ ## Common P1 patterns -### Devs can't claim sessions +### Devs can't get a sandbox -1. Check session-router pod status. `CrashLoopBackOff`? → logs. -2. Check warm pool: `kubectl -n agent-pool get pods -l state=warm | wc -l`. Should be ≥ `agentPod.keda.minReplicas`. -3. If pool is empty: `kubectl describe scaledobject agent-pod -n agent-pool` — KEDA scaler healthy? -4. If KEDA is healthy but pods are `Pending`: spot capacity. Bump `agentPool` node-pool with on-demand fallback. +1. Check orchestrator pod status. `CrashLoopBackOff`? → logs. +2. Check warm pool: `kubectl -n agent-sandboxes get sandboxwarmpool -o wide`. Is `readyReplicas` near `replicas`? +3. If the pool is empty/not filling: check the controller — `kubectl -n agent-sandbox-system logs deploy/agent-sandbox-controller --tail=100`. CRDs installed? (`kubectl get crd | grep agents.x-k8s.io`). +4. If sandboxes are stuck `Pending`: node capacity. `kubectl -n agent-sandboxes describe sandbox ` for the `FailedScheduling` event; bump the node pool. +5. Getting HTTP 429? Admission caps hit — raise `sandboxOrchestrator.concurrency.maxTotal` / `maxPerDev`. ### Model gateway 401s @@ -86,22 +88,23 @@ curl -H "x-litellm-api-key: $LITELLM_MASTER_KEY" \ - Check per-deployment TPM in Foundry portal. If saturating, add a second deployment in another region and update LiteLLM `config.yaml` to fall back. - Per-dev RPM cap in LiteLLM is too generous? Tighten `modelGateway.budgets.default.rpmLimit`. -### Spot eviction storm +### Sandboxes stuck Pending / pool not filling -- Check Service Bus `agent-pod-claims` queue depth — if it's growing, KEDA is asking for pods that can't schedule. -- Tactical: scale `agentPod` `nodeSelector` to a non-spot node pool (helm value override + `helm upgrade`). -- Strategic: diversify spot SKUs in the AKS node pool spec. +- Check the warm pool: `kubectl -n agent-sandboxes get sandboxwarmpool -o wide` — is `readyReplicas` climbing toward `replicas`? +- Check the controller is healthy and has the `--extensions` flag: `kubectl -n agent-sandbox-system get deploy agent-sandbox-controller -o jsonpath='{.spec.template.spec.containers[0].args}'`. +- Node capacity: `kubectl -n agent-sandboxes describe sandbox ` and look for `FailedScheduling`. +- After editing the `SandboxTemplate`, live warm pods are NOT auto-recreated — delete them by name so the pool re-stamps: `kubectl -n agent-sandboxes delete sandbox `. ## Scaling -### Increase warm pool baseline +### Increase warm pool / concurrency ```bash helm upgrade code-forge charts/code-forge \ -f charts/code-forge/values-prod.yaml \ --reuse-values \ - --set agentPod.replicas=120 \ - --set agentPod.keda.minReplicas=120 + --set sandboxOrchestrator.sandbox.warmpoolReplicas=8 \ + --set sandboxOrchestrator.concurrency.maxTotal=200 ``` ### Add a new model @@ -120,8 +123,6 @@ NEW=$(openssl rand -hex 32) az keyvault secret set --vault-name kv-codeforge --name litellm-master --value "sk-$NEW" # CSI driver picks it up on next pod restart: kubectl -n platform rollout restart deploy/model-gateway -# Cycle all virtual keys (router will mint new ones on next claim): -kubectl -n agent-pool delete secret -l app=agent-pod ``` ### TLS cert (quarterly, automated via cert-manager) @@ -145,30 +146,32 @@ az identity federated-credential update \ | Thing | Backup | RTO | RPO | |---|---|---|---| -| Cosmos session audit | Continuous backup, 30 days | 15 min | 5 min | -| Redis | Replication only — disposable | n/a | session-bound | -| Workspace PVCs (Azure Files) | Snapshot daily | 1 h | 24 h | +| Foundry usage / cost log (LiteLLM) | LiteLLM DB backup | 15 min | 5 min | +| Sandboxes | Disposable — single-use, no durable state | n/a | n/a | +| Optional per-dev PVCs (Azure Files) | Snapshot daily | 1 h | 24 h | | Key Vault | Soft-delete + purge protection (90 days) | 1 h | 0 | | ACR | Geo-replication to a paired region | n/a | tag-bound | | Helm release | `helm history` (in cluster) + chart in git | minutes | 0 | -DR drill: quarterly. Restore Cosmos from PITR + redeploy the chart in the -paired region's pre-built standby cluster. +DR drill: quarterly. Redeploy the chart (+ agent-sandbox CRDs/controller) in the +paired region's pre-built standby cluster; the warm pool re-fills automatically. ## Useful one-liners ```bash -# How many devs are bound right now? -kubectl -n agent-pool get pods -l state=bound -o jsonpath='{.items[*].metadata.labels.dev-id}' \ - | tr ' ' '\n' | sort -u | wc -l +# How many sandboxes are in flight right now? +kubectl -n agent-sandboxes get sandboxclaims --no-headers | wc -l + +# Warm pool readiness +kubectl -n agent-sandboxes get sandboxwarmpool -o jsonpath='{.items[0].status.readyReplicas}/{.items[0].status.replicas}'; echo # Top spenders this hour curl -H "x-litellm-api-key: $LITELLM_MASTER_KEY" \ https://api.codeforge.example.com/spend/users \ | jq 'sort_by(-.spend)[:10]' -# Force-evict a stuck dev -kubectl -n agent-pool label pod $POD state=cooldown --overwrite +# Force-delete a stuck sandbox + its claim +kubectl -n agent-sandboxes delete sandboxclaim ; kubectl -n agent-sandboxes delete sandbox # Drain a node gracefully kubectl drain $NODE --ignore-daemonsets --delete-emptydir-data --grace-period=120 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 88b48d1..9a2e02a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,27 +4,27 @@ ## Trust boundary -The hard boundary is the **agent pod**. Anything inside the pod is treated as -a potentially compromised process: a malicious package the developer +The hard boundary is the **agent sandbox**. Anything inside the sandbox is +treated as a potentially compromised process: a malicious package the developer installed, a prompt-injection from a malicious repo, a buggy MCP server. -Everything outside the pod (router, gateway, Foundry, Azure plane) is in a -strictly higher trust tier. The controls below enforce one-way trust: the pod -cannot reach back into the platform. +Everything outside the sandbox (orchestrator, gateway, Foundry, Azure plane) is +in a strictly higher trust tier. The controls below enforce one-way trust: the +sandbox cannot reach back into the platform. ## Threat model | Threat | Asset at risk | Control | |---|---|---| -| Malicious code in a repo Claude Code reads | Other devs' workspaces, Foundry credentials | Per-pod ephemeral workspace; PVC scoped to one `(dev, project)`; AAD token never on the pod | +| Malicious code in a repo Claude Code reads | Other devs' work, Foundry credentials | Single-use ephemeral sandbox (no shared state); optional per-dev PVC scoped by Azure RBAC; AAD token never on the sandbox | | Prompt injection makes Claude exfiltrate data | Source code, secrets | NetworkPolicy default-deny egress (only gateway reachable); no `~/.aws`, `~/.azure`, `~/.ssh` mounted | -| Compromised dev laptop | Foundry budget abuse | Per-dev virtual keys with hard `max_budget` + `rpm` caps in LiteLLM | +| Compromised dev laptop | Foundry budget abuse | Per-dev `max_budget` + `rpm` caps enforced at the gateway (`modelGateway.budgets`) | | Compromised gateway pod | All in-flight Foundry traffic | AAD token sidecar uses Workload Identity, can't be exfiltrated as a static secret; rotated every ~50 min | -| Stolen LiteLLM master key | Ability to mint new virtual keys | Master key in Key Vault → CSI driver mount; rotated quarterly; access scoped to gateway MI | -| Leaked agent virtual key | Spend up to that key's `max_budget` | Per-session, short-lived (TTL = idle timeout); revoked on session release | +| Stolen LiteLLM master key | Ability to mint new keys / change budgets | Master key in Key Vault → CSI driver mount; rotated quarterly; access scoped to gateway MI | +| Orchestrator compromise | Ability to provision/claim sandboxes | Stateless; namespaced RBAC limited to `Sandbox`/`SandboxClaim` in `agent-sandboxes`; cannot read platform secrets | | AKS API server compromise | Cluster takeover | Private cluster + AAD-only auth + Conditional Access with MFA; audit to Log Analytics | -| Foundry deployment quota exhaustion | DoS for all devs | Multi-deployment + LiteLLM fallback; per-dev RPM cap is the main throttle | -| Spot-eviction-driven session migration | Mid-flight tokens leak across sessions | preStop hook scrubs `/workspace`; new pod starts from a fresh image; PVC content survives but is per-`(dev,project)` | +| Foundry deployment quota exhaustion | DoS for all devs | Multi-deployment + LiteLLM fallback; per-dev RPM cap + orchestrator concurrency caps are the main throttles | +| Sandbox reuse across devs | Cross-dev data leak | Sandboxes are single-use — a claim is deleted on completion/TTL and the warm pool re-stamps a fresh pod from the image | ## Controls — defense in depth @@ -32,12 +32,12 @@ cannot reach back into the platform. - **Azure Workload Identity** (federated OIDC) for every workload. No SP secrets, no static API keys, no managed-identity-via-IMDS. One UAMI per workload role with the minimum role on the minimum scope. - **AAD-only auth on AKS API server.** No local accounts, no kubeconfig sharing. -- **Per-session virtual keys** for Foundry traffic. Revocable in O(1) by deleting the K8s secret. +- **Gateway-enforced budgets** for Foundry traffic. Per-dev `max_budget` + `rpm` caps live in LiteLLM config; tightening a budget is an O(1) config change. ### Network -- **Default-deny `NetworkPolicy`** in `agent-pool` and `platform`. -- Agent pods can reach: cluster DNS + `model-gateway` Service. That's it. +- **Default-deny `NetworkPolicy`** in `agent-sandboxes` and `platform`. +- Agent sandboxes can reach: cluster DNS + `model-gateway` Service. That's it. - Gateway can reach: cluster DNS + 443 outbound (to Foundry). Lock further with private endpoint to Foundry. - Front Door + WAF on the public ingress. mTLS between Front Door and the AKS ingress controller. @@ -45,14 +45,14 @@ cannot reach back into the platform. - **PSA `restricted`** enforced at namespace level. Non-root, no privilege escalation, drop ALL caps, RuntimeDefault seccomp, read-only root FS. - **No host mounts.** No `~/.ssh`, no `/var/run/docker.sock`, no host network, no hostPID, no hostIPC. -- **Ephemeral workspace** wiped on every session release. +- **Single-use sandboxes.** A sandbox serves one claim, then the claim is deleted and the pod is discarded — no workspace survives across requests. - **Resource quotas** per namespace + LimitRange to prevent runaway pods exhausting the node. ### Data -- **Cosmos** session audit: `dev_id`, `pod_name`, `bound_at`, `released_at`, `tokens_in`, `tokens_out`. Customer-managed key (CMK) on the account. -- **Redis** is hot-path only — no PII, just session ↔ pod mapping. TTL-bounded. -- **Workspace PVCs** on Azure Files with CMK. Per-`(dev, project)` ACL via Azure RBAC on the share. +- **Audit / cost log** lives in the model-gateway (LiteLLM): `dev_id`, `model`, `tokens_in`, `tokens_out`, `spend`, timestamps. CMK-backed store. +- **No session-state datastore.** The orchestrator is stateless; sandbox state lives only in the kube API (`SandboxClaim`/`Sandbox` objects) and is torn down on completion. +- **Optional per-dev PVCs** on Azure Files with CMK, ACL'd per dev via Azure RBAC on the share (only if durable scratch is enabled). - **Key Vault** for the LiteLLM master key, TLS certs, any other root-of-trust secrets. Soft-delete + purge protection. ### Supply chain @@ -63,7 +63,7 @@ cannot reach back into the platform. ### Operations -- **Audit log** of every router decision, every gateway call. Shipped to Log Analytics + retained 90 days. +- **Audit log** of every orchestrator decision, every gateway call. Shipped to Log Analytics + retained 90 days. - **Cost alerts** at 50/80/100% of monthly Foundry budget per environment. - **Quarterly rotation** of LiteLLM master key, AKS local accounts disabled. - **Pen test** annually + scoped pen test on the gateway after major changes. @@ -77,14 +77,14 @@ We chose **not** to do these — at least not yet — and the reasoning: | **gVisor / Kata sandboxing** | Adds operational complexity; threat model already addressed by PSA + NetworkPolicy + non-root + ephemeral FS. Revisit if we onboard customers with stricter compliance | | **Per-tenant cluster** | Cost — multi-tenancy via namespace + RBAC + NetworkPolicy is industry-standard for this trust level | | **End-to-end encryption of the I/O channel** | TLS terminates at Front Door; cluster-internal is plaintext over the AKS service network. If we move to a service mesh (Istio mTLS) this is free; until then, the threat model treats the cluster as a single trust zone | -| **Customer keys for Foundry** | One Foundry resource per environment; per-tenant data isolation enforced via virtual keys + Cosmos partition keys | +| **Customer keys for Foundry** | One Foundry resource per environment; per-tenant data isolation enforced via gateway budgets + per-dev tagging in the audit log | ## Incident response See `docs/OPERATIONS.md` → "Security incidents". Quick map: -- **Suspected key leak** → revoke virtual keys (`router /admin/revoke`), rotate LiteLLM master, audit Cosmos for the `dev_id`. -- **Suspected pod compromise** → cordon the node, capture pod (`kubectl debug`), force-recycle the pool. +- **Suspected key leak** → tighten/zero the dev's budget in LiteLLM, rotate the LiteLLM master key, audit the gateway log for the `dev_id`. +- **Suspected sandbox compromise** → cordon the node, capture the pod (`kubectl debug`), delete the `SandboxClaim`/`Sandbox`; the warm pool re-stamps clean. - **Suspected gateway compromise** → scale gateway to 0, rotate AAD federation, force AAD secret rotation, redeploy. ## Reporting From 450036650d622023385cba3f323be9bcdc1c0fac Mon Sep 17 00:00:00 2001 From: Michael Liav Date: Tue, 9 Jun 2026 11:41:12 +0300 Subject: [PATCH 6/8] update --- charts/code-forge/values-prod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/code-forge/values-prod.yaml b/charts/code-forge/values-prod.yaml index db1076a..c264063 100644 --- a/charts/code-forge/values-prod.yaml +++ b/charts/code-forge/values-prod.yaml @@ -1,7 +1,7 @@ # Production overlay — copy and customize per environment. global: registry: codeforgedemo.azurecr.io - azureTenantId: "61800350-09d8-4051-942c-5b732fcaa307" + azureTenantId: "" foundry: resource: admin-mgrojh0e-eastus2 models: From 6f5b9bbbf07f595e70e8963c411494021c32bf7f Mon Sep 17 00:00:00 2001 From: Michael Liav Date: Tue, 9 Jun 2026 11:42:46 +0300 Subject: [PATCH 7/8] update --- Makefile | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index bdd9481..3548671 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,10 @@ RELEASE ?= code-forge NAMESPACE ?= session-control # AKS nodes are linux/amd64; build for that platform even on Apple Silicon. PLATFORM ?= linux/amd64 +# agent-sandbox controller + CRDs (core Sandbox + extensions: SandboxTemplate/ +# SandboxWarmPool/SandboxClaim). https://github.com/kubernetes-sigs/agent-sandbox +AGENT_SANDBOX_VERSION ?= v0.4.6 +AGENT_SANDBOX_BASE ?= https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$(AGENT_SANDBOX_VERSION) .PHONY: help help: @@ -15,7 +19,8 @@ help: @echo " push-images # docker push to \$$ACR" @echo " chart-lint # helm lint" @echo " chart-template # helm template (preview rendered yaml)" - @echo " chart-install # helm upgrade --install" + @echo " install-crds # kubectl apply agent-sandbox CRDs + controller" + @echo " chart-install # install-crds then helm upgrade --install" @echo " chart-uninstall # helm uninstall" .PHONY: build-images @@ -51,8 +56,14 @@ chart-template: --set workloadIdentity.modelGateway.clientId=33333333-3333-3333-3333-333333333333 \ --set agentPod.keda.serviceBus.namespace=demo.servicebus.windows.net +.PHONY: install-crds +install-crds: + kubectl apply --server-side -f $(AGENT_SANDBOX_BASE)/manifest.yaml + kubectl apply --server-side -f $(AGENT_SANDBOX_BASE)/extensions.yaml + kubectl -n agent-sandbox-system rollout status deploy/agent-sandbox-controller --timeout=120s + .PHONY: chart-install -chart-install: +chart-install: install-crds helm upgrade --install $(RELEASE) charts/code-forge \ --create-namespace --namespace $(NAMESPACE) \ -f charts/code-forge/values-prod.yaml From ba8796fe8f47e52634f39d047ca20510aac3c20f Mon Sep 17 00:00:00 2001 From: Michael Liav Date: Tue, 9 Jun 2026 11:48:31 +0300 Subject: [PATCH 8/8] update make file --- Makefile | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 3548671..1fe9bac 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,18 @@ PLATFORM ?= linux/amd64 AGENT_SANDBOX_VERSION ?= v0.4.6 AGENT_SANDBOX_BASE ?= https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$(AGENT_SANDBOX_VERSION) +# Placeholder values used only for `helm lint` / `helm template` previews. +FOUNDRY_RESOURCE ?= demo +AZURE_TENANT_ID ?= 00000000-0000-0000-0000-000000000000 +ORCHESTRATOR_CLIENT_ID ?= 11111111-1111-1111-1111-111111111111 +GATEWAY_CLIENT_ID ?= 33333333-3333-3333-3333-333333333333 +# Shared --set flags for the lint/template demo render. +DEMO_SET = \ + --set global.foundry.resource=$(FOUNDRY_RESOURCE) \ + --set global.azureTenantId=$(AZURE_TENANT_ID) \ + --set workloadIdentity.sandboxOrchestrator.clientId=$(ORCHESTRATOR_CLIENT_ID) \ + --set workloadIdentity.modelGateway.clientId=$(GATEWAY_CLIENT_ID) + .PHONY: help help: @echo "Targets:" @@ -38,23 +50,11 @@ push-images: .PHONY: chart-lint chart-lint: - helm lint charts/code-forge \ - --set global.foundry.resource=demo \ - --set global.azureTenantId=00000000-0000-0000-0000-000000000000 \ - --set workloadIdentity.agentPod.clientId=11111111-1111-1111-1111-111111111111 \ - --set workloadIdentity.sessionRouter.clientId=22222222-2222-2222-2222-222222222222 \ - --set workloadIdentity.modelGateway.clientId=33333333-3333-3333-3333-333333333333 \ - --set agentPod.keda.serviceBus.namespace=demo.servicebus.windows.net + helm lint charts/code-forge $(DEMO_SET) .PHONY: chart-template chart-template: - @helm template $(RELEASE) charts/code-forge \ - --set global.foundry.resource=demo \ - --set global.azureTenantId=00000000-0000-0000-0000-000000000000 \ - --set workloadIdentity.agentPod.clientId=11111111-1111-1111-1111-111111111111 \ - --set workloadIdentity.sessionRouter.clientId=22222222-2222-2222-2222-222222222222 \ - --set workloadIdentity.modelGateway.clientId=33333333-3333-3333-3333-333333333333 \ - --set agentPod.keda.serviceBus.namespace=demo.servicebus.windows.net + @helm template $(RELEASE) charts/code-forge $(DEMO_SET) .PHONY: install-crds install-crds: