chore: move infra/ into a private submodule

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 00:06:38 +02:00
parent 19c4354224
commit a5c42de28c
6 changed files with 5 additions and 348 deletions
+1 -1
View File
@@ -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.
-1
View File
@@ -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/
+3
View File
@@ -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
Submodule
+1
Submodule infra added at 4845355ec7
@@ -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 <dcrId-from-above>
//
// 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 <rg> -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
-268
View File
@@ -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 <rg-name> \
// --template-file infra/mcp-server.bicep \
// --parameters apiUrl=https://autopilotmonitor-api-eu.azurewebsites.net \
// entraClientSecret=<secret-value>
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.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=<containerAppUrl from step 1>
//
// 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 <acr-id> --assignee <containerApp principalId>
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