From a5c42de28c90da09c581a552cee161b1c3eaaa81 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 2 Sep 2026 00:06:38 +0200 Subject: [PATCH] chore: move infra/ into a private submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Bicep templates — and the deployment scripts still to come — do not belong in a public repository. They now live in okieselbach/Autopilot-Monitor-infra (full history via git-filter-repo) and are mounted back at infra/, so the code comments pointing at infra/mcp-server.bicep stay valid. No workflow reads infra/, so CI is unaffected. The *.parameters.json ignore rule moved into the submodule. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/CLAUDE.md | 2 +- .gitignore | 1 - .gitmodules | 3 + infra | 1 + infra/appinsights-workspace-transforms.bicep | 78 ------ infra/mcp-server.bicep | 268 ------------------- 6 files changed, 5 insertions(+), 348 deletions(-) create mode 160000 infra delete mode 100644 infra/appinsights-workspace-transforms.bicep delete mode 100644 infra/mcp-server.bicep diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 475f8c5d..37e0c216 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -34,5 +34,5 @@ Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` re - Write a concept doc only for durable knowledge a later change could silently break: invariants, architecture decisions, non-obvious mechanisms — not for every feature. YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`. - Register it in `docs/index.md`. There is no `log.md` — the commit message is the log. `index.md` is a reserved name. -- Commit inside the submodule first, then bump the gitlink here (same rule as `.claude/commands`). +- Commit inside the submodule first, then bump the gitlink here (same rule as `.claude/commands` and the private `infra/` submodule, `okieselbach/Autopilot-Monitor-infra`). - English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation. diff --git a/.gitignore b/.gitignore index 5976d8ee..10ed73ff 100644 --- a/.gitignore +++ b/.gitignore @@ -124,7 +124,6 @@ scripts/Migration/ # @secure() parameter with an empty default, injected at deploy time, and image # pull runs on managed identity. Keep it that way: never commit a resolved secret # value or a *.parameters.json holding one. -infra/*.parameters.json # M2 — local raw session fixtures before anonymization (plan §4 M2) tests/fixtures/enrollment-sessions-local/ diff --git a/.gitmodules b/.gitmodules index 7ff65edd..eb50bf96 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "docs"] path = docs url = https://github.com/okieselbach/Autopilot-Monitor-docs-internal.git +[submodule "infra"] + path = infra + url = https://github.com/okieselbach/Autopilot-Monitor-infra.git diff --git a/infra b/infra new file mode 160000 index 00000000..4845355e --- /dev/null +++ b/infra @@ -0,0 +1 @@ +Subproject commit 4845355ec7aba3ff9778bc0f9b19fe88750372ac diff --git a/infra/appinsights-workspace-transforms.bicep b/infra/appinsights-workspace-transforms.bicep deleted file mode 100644 index 0a95f539..00000000 --- a/infra/appinsights-workspace-transforms.bicep +++ /dev/null @@ -1,78 +0,0 @@ -// Autopilot-Monitor — Log Analytics workspace transformation (ingestion-time filter) -// -// Two ingestion-time filters. (2) Successful Azure SignalR REST dependencies emitted by the -// HOST process for the SignalR output binding (SDKVersion rdddsc:*) — the worker-side -// StorageDependencyFilterProcessor never sees those. Failed SignalR calls are kept. -// (1) Drops the redundant HOST-side HTTP request record from AppRequests so that exactly one -// canonical request row remains: the worker-side copy emitted by RequestTelemetryMiddleware -// (Properties.Source == 'WorkerMiddleware', carries TenantId/UserId/CorrelationId and is -// sampling-exempt per item). Non-HTTP host invocations (timer/queue triggers) have an empty -// Url and are kept — the worker middleware never sees them. -// -// Why ingestion-time: the host request item is emitted by the Functions host process and -// cannot be suppressed from worker code or host.json (verified 2026-06-09, see Program.cs). -// Live numbers 2026-08-23 (7d): host copy 274k rows / 323 MB, worker copy 189k rows / 208 MB. -// -// Cost note: transformation filtering is free up to 50% of the table's incoming volume; the -// portion beyond that (here ~11% of AppRequests) is billed at the (cheap) data-processing rate. -// -// Apply (two steps; the link is a CLI update = GET+PUT, which keeps retention/SKU intact): -// az deployment group create -g rg-autopilotmonitor-prd-gwc \ -// --template-file infra/appinsights-workspace-transforms.bicep \ -// --query properties.outputs.dcrId.value -o tsv -// az monitor log-analytics workspace update -g rg-autopilotmonitor-prd-gwc -n AutopilotMonitor \ -// --data-collection-rule -// -// Verify after ~20 min (ingestion latency): -// AppRequests | where TimeGenerated > ago(15m) | where AppRoleName == 'autopilotmonitor-api-eu' -// | summarize rows=count() by source=tostring(Properties.Source), hasUrl=isnotempty(Url) -// → only (WorkerMiddleware, true) and ('', false) buckets remain. -// -// Rollback: az monitor log-analytics workspace update -g -n AutopilotMonitor -// --set properties.defaultDataCollectionRuleResourceId=null (then delete the DCR) - -@description('Log Analytics workspace that backs the workspace-based Application Insights resources.') -param workspaceName string = 'AutopilotMonitor' - -param location string = resourceGroup().location - -resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { - name: workspaceName -} - -resource transforms 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { - name: 'dcr-autopilotmonitor-ws-transforms' - location: location - kind: 'WorkspaceTransforms' - properties: { - description: 'AppRequests: keep the enriched worker request record (Properties.Source == WorkerMiddleware) and non-HTTP host invocations (empty Url); drop the host HTTP duplicate.' - destinations: { - logAnalytics: [ - { - workspaceResourceId: workspace.id - name: 'ws' - } - ] - } - dataFlows: [ - { - streams: [ 'Microsoft-Table-AppRequests' ] - destinations: [ 'ws' ] - transformKql: 'source | where tostring(Properties.Source) == \'WorkerMiddleware\' or isempty(Url)' - } - { - // Successful Azure SignalR REST calls (group add/remove per connection) are emitted by - // the HOST process for the SignalR output binding (SDKVersion rdddsc:*), so the worker's - // StorageDependencyFilterProcessor never sees them. Failed SignalR calls are kept. - streams: [ 'Microsoft-Table-AppDependencies' ] - destinations: [ 'ws' ] - transformKql: 'source | where not(Success == true and Target endswith \'.service.signalr.net\')' - } - ] - } -} - -// A workspace has exactly one transformation DCR, activated by linking it as the workspace's -// defaultDataCollectionRuleResourceId — deliberately NOT modelled here (see Apply above). - -output dcrId string = transforms.id diff --git a/infra/mcp-server.bicep b/infra/mcp-server.bicep deleted file mode 100644 index 80fbe698..00000000 --- a/infra/mcp-server.bicep +++ /dev/null @@ -1,268 +0,0 @@ -// Autopilot-Monitor MCP Server — Azure Container App Infrastructure -// -// Reconciled against the live deployment on 2026-07-21 (rg-autopilotmonitor-prd-gwc, -// Germany West Central). The template had drifted behind two changes made directly -// in Azure: the EU cutover (registry/API renamed) and the ACR hardening (admin user -// disabled, image pull moved to the app's system-assigned identity). -// -// !! BEFORE RE-DEPLOYING THIS TEMPLATE, READ THIS !! -// The live app carries an ingress custom-domain binding for mcp.autopilotmonitor.com -// (SNI-enabled, with a managed certificate). Custom domains are NOT modelled below, -// because the certificate is bound out-of-band. A full template deployment would -// therefore REMOVE that binding and take the public MCP endpoint offline. -// For routine changes (CPU/memory, env vars, image) prefer the surgical CLI form: -// az containerapp update -n autopilotmonitor-mcp -g rg-autopilotmonitor-prd-gwc --cpu 0.5 --memory 1.0Gi -// Treat this file as the documented desired state and as a rebuild-from-scratch -// recipe — not as a routinely-applied deployment. -// -// On a FROM-SCRATCH deploy the AcrPull assignment below is created only after the -// Container App exists (it needs the app's principalId), so the very first revision -// can fail to pull its image. Re-run the deployment, or restart the revision, once -// the role assignment has propagated. This does not affect updates to a live app. -// -// Deploy (two-stage; required to set the OAuth public URL safely): -// 1. First deploy without mcpPublicUrl: -// az deployment group create \ -// --resource-group \ -// --template-file infra/mcp-server.bicep \ -// --parameters apiUrl=https://autopilotmonitor-api-eu.azurewebsites.net \ -// entraClientSecret= -// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp...azurecontainerapps.io). -// -// 2. Re-deploy with that URL pinned, so the OAuth proxy never trusts caller- -// supplied X-Forwarded-Host / Host headers when building issuer / WWW- -// Authenticate / redirect_uri metadata: -// --parameters mcpPublicUrl= -// -// When mcpPublicUrl is set, the MCP server uses it verbatim and ignores any -// forwarded-headers from the request. Without it, the server logs a startup -// warning and falls back to header-derived URLs (acceptable for local dev, -// not for production). - -@description('Azure region for all resources') -param location string = resourceGroup().location - -@description('Name of the Container App') -param containerAppName string = 'autopilotmonitor-mcp' - -@description('Name of the Container App Environment') -param environmentName string = 'autopilotmonitor-env' - -@description('Name of the Log Analytics Workspace (dedicated for Container Apps)') -param logAnalyticsName string = 'autopilotmonitor-container-logs' - -@description('Name of the Azure Container Registry') -param acrName string = 'autopilotmonitoreuacr' - -@description('Backend API URL') -param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net' - -@description('Container image tag') -param imageTag string = 'latest' - -@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).') -param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731' - -@secure() -@description('Entra ID client secret for OAuth token exchange') -param entraClientSecret string = '' - -@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).') -param mcpAllowedRedirectHosts string = '' - -@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).') -param mcpPublicUrl string = '' - -@secure() -// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode -// >=). `az bicep build --stdout` writes through the Windows console codec and -// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine. -@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))') -param oAuthStateSigningKey string = '' - -@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.') -param toolLoggingEnabled bool = true - -// --- Azure Container Registry --- - -// Admin user is DISABLED: a registry-wide username/password pair is a static, -// non-rotating credential that would also have to live in a Container App secret. -// The app pulls with its system-assigned identity + AcrPull instead (see below). -resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { - name: acrName - location: location - sku: { - name: 'Basic' - } - properties: { - adminUserEnabled: false - } -} - -// AcrPull for the Container App's system-assigned identity — this is what replaces -// the admin credentials. Without it the app cannot pull its own image. -// -// NOTE: this assignment exists on the live registry but could not be enumerated -// during reconciliation (the CLI session lacks Microsoft.Authorization read), so it -// is reproduced here from the documented pattern rather than read back from Azure. -// Verify with: -// az role assignment list --scope --assignee -var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' - -resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: acr - name: guid(acr.id, containerAppName, acrPullRoleId) - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId) - principalId: containerApp.identity.principalId - principalType: 'ServicePrincipal' - } -} - -// --- Log Analytics Workspace (required by Container App Environment) --- - -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: logAnalyticsName - location: location - properties: { - sku: { - name: 'PerGB2018' - } - retentionInDays: 30 - } -} - -// --- Container App Environment --- - -resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = { - name: environmentName - location: location - properties: { - appLogsConfiguration: { - destination: 'log-analytics' - logAnalyticsConfiguration: { - customerId: logAnalytics.properties.customerId - sharedKey: logAnalytics.listKeys().primarySharedKey - } - } - } -} - -// --- Container App --- - -resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { - name: containerAppName - location: location - // System-assigned identity: the credential the app uses to pull from the ACR - // (see the AcrPull assignment above). Replaces the former admin-user secret. - identity: { - type: 'SystemAssigned' - } - properties: { - managedEnvironmentId: environment.id - configuration: { - ingress: { - external: true - targetPort: 8080 - transport: 'auto' - allowInsecure: false - } - // 'system' = pull with the app's system-assigned identity. No username, no - // password secret — nothing to rotate and nothing to leak from the app config. - registries: [ - { - server: acr.properties.loginServer - identity: 'system' - } - ] - secrets: [ - { - name: 'entra-client-secret' - value: entraClientSecret - } - { - name: 'oauth-state-signing-key' - value: oAuthStateSigningKey - } - ] - activeRevisionsMode: 'Single' - } - template: { - containers: [ - { - name: 'mcp-server' - image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}' - // 0.5 vCPU, but NOT for the reason originally given. It was raised from - // 0.25 to shorten the scale-to-zero cold start, on the assumption that - // loading the ONNX embedding model dominated it. Measurement disproved - // that: the app boots in 504 ms in production (see the [boot] marks in - // the container log and docs/mcp/docs-corpus.md), of which the prebaked - // embedder is 173 ms in the background. Of a 31 s cold start, ~98% is - // Container Apps activation — pod scheduling, image mount, container - // start — which no CPU setting touches. - // - // It stays at 0.5 because it plausibly helps CONCURRENT requests - // (maxReplicas is 1, so one replica serves up to 10 in-flight calls) and - // because at scale-to-zero the container bills only while active, making - // the difference negligible. If cold start ever needs to actually go - // away, the only lever is minReplicas: 1. Container Apps accepts fixed - // CPU/memory pairs, so memory moves with the CPU. - resources: { - cpu: json('0.5') - memory: '1.0Gi' - } - env: [ - { name: 'AUTOPILOT_API_URL', value: apiUrl } - { name: 'PORT', value: '8080' } - { name: 'AUTOPILOT_ENTRA_CLIENT_ID', value: entraClientId } - { name: 'AUTOPILOT_ENTRA_CLIENT_SECRET', secretRef: 'entra-client-secret' } - { name: 'MCP_ALLOWED_REDIRECT_HOSTS', value: mcpAllowedRedirectHosts } - { name: 'MCP_PUBLIC_URL', value: mcpPublicUrl } - { name: 'OAuthStateSigningKey', secretRef: 'oauth-state-signing-key' } - { name: 'MCP_TOOL_LOGGING', value: toolLoggingEnabled ? 'true' : 'false' } - ] - probes: [ - { - type: 'Liveness' - httpGet: { - path: '/health' - port: 8080 - } - periodSeconds: 30 - initialDelaySeconds: 10 - } - { - type: 'Readiness' - httpGet: { - path: '/health' - port: 8080 - } - periodSeconds: 10 - initialDelaySeconds: 5 - } - ] - } - ] - scale: { - minReplicas: 0 - maxReplicas: 1 - rules: [ - { - name: 'http-scaler' - http: { - metadata: { - concurrentRequests: '10' - } - } - } - ] - } - } - } -} - -// --- Outputs --- - -output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}' -output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp' -output acrLoginServer string = acr.properties.loginServer