diff --git a/.claude/commands b/.claude/commands index 62fec7ec..af1b8468 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 62fec7ecfa540cb64a2011324c308545a9a47c99 +Subproject commit af1b8468c72d9101c2400078f6bd839ef3b3904e diff --git a/.github/scripts/Request-BuildNumber.ps1 b/.github/scripts/Request-BuildNumber.ps1 new file mode 100644 index 00000000..76f2803e --- /dev/null +++ b/.github/scripts/Request-BuildNumber.ps1 @@ -0,0 +1,102 @@ +<# +.SYNOPSIS + Reserves a monotone build number from a counter blob (ETag compare-and-swap). + +.DESCRIPTION + Single source of build numbers for every component that ships from this repo + (agent, backend, MCP). Each component owns one counter blob holding a plain + integer; this script reads it, writes value+1 back under If-Match, and returns + the reserved number. + + Reserve-BEFORE-build: two concurrent builds can never mint the same number. + A failed build burns a number, which is harmless -- uniqueness matters, + density does not. + + The blob is public-read; only the write needs the container SAS. + + Why a counter and not the published version manifest: manifests are written + on publish, not on build (the agent writes version.json only for stable + releases), so two dev builds in a row would derive the same number and + overwrite each other's versioned artifact. + +.PARAMETER CounterUrl + Full URL of the counter blob, without SAS. + +.PARAMETER SasToken + Container SAS with create+write permission. A leading '?' is tolerated. + +.PARAMETER Override + Explicit build number instead of counter+1. If it is not greater than the + current counter the blob is left untouched (re-build of a number that was + already reserved). 0 = no override. + +.PARAMETER MaxAttempts + CAS retries before giving up. + +.OUTPUTS + [int] the reserved build number. Progress goes to the host, not the pipeline. + +.EXAMPLE + $n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$CounterUrl, + [Parameter(Mandatory = $true)][string]$SasToken, + [int]$Override = 0, + [int]$MaxAttempts = 5 +) + +$ErrorActionPreference = 'Stop' + +# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&..."). +$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?') + +function Get-HttpStatus { + param($ErrorRecord) + if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { + return [int]$ErrorRecord.Exception.Response.StatusCode + } + return 0 +} + +function Read-Counter { + try { + $resp = Invoke-WebRequest -Uri $CounterUrl -Method Get -UseBasicParsing + } catch { + if ((Get-HttpStatus $_) -eq 404) { + throw "Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md." + } + throw + } + $raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content } + # If-Match requires the quoted ETag form; PowerShell strips the quotes on read. + $etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"' + return [pscustomobject]@{ Value = [int]$raw.Trim(); ETag = $etag } +} + +for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $counter = Read-Counter + + if ($Override -gt 0 -and $Override -le $counter.Value) { + Write-Host " Build number $Override <= counter $($counter.Value) -- already reserved, counter blob unchanged" + return $Override + } + $target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 } + + try { + $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag } + Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null + Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)" + return $target + } catch { + if ((Get-HttpStatus $_) -eq 412) { + Write-Host " CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying" + Start-Sleep -Seconds ($attempt * 2) + } else { + throw + } + } +} + +throw "Could not reserve a build number after $MaxAttempts CAS attempts" diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index ddb7853a..1fc23aab 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -64,70 +64,19 @@ jobs: with: dotnet-version: '10.0.x' - # Build numbers come from the shared counter blob (single source of truth for CI - # AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can - # never mint the same number; a failed build burns a number, which is harmless. - # The blob is public-read (like the rest of the container); writes need the SAS. + # Build numbers come from the shared counter blob -- one counter and one + # implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1). + # Reserve-before-build with ETag-CAS: concurrent builds can never mint the same + # number; a failed build burns a number, which is harmless. - name: Reserve build number (counter blob CAS) id: version shell: pwsh run: | - $counterBlob = "buildcounter-v$env:LINE_MAJOR.txt" - $counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/$counterBlob" - # Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...") -- the URL below adds the '?'. - $sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?') - $override = "${{ inputs.build_number }}" + $override = 0 + if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' } - function Get-Counter { - try { - $resp = Invoke-WebRequest -Uri $counterUrl -Method Get - $raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content } - # If-Match requires the quoted ETag form; pwsh strips the quotes on read. - $etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"' - return @{ Value = [int]$raw.Trim(); ETag = $etag } - } catch { - $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } - if ($status -eq 404) { - Write-Error "Counter blob '$counterBlob' not found. Seed it once with the last used build number (see docs/agent/build-counter-blob.md) -- do NOT seed from the repo's buildcounter.txt, it is a stale offline fallback." - } - throw - } - } - - $buildNumber = $null - for ($attempt = 1; $attempt -le 5; $attempt++) { - $counter = Get-Counter - if ($override -ne '') { - $target = [int]$override - if ($target -le $counter.Value) { - # Re-build of an already-reserved number: counter stays monotonic, no write. - Write-Host "Manual build number $target <= counter $($counter.Value) -- counter blob unchanged" - $buildNumber = $target - break - } - Write-Host "Manual build number $target > counter $($counter.Value) -- advancing counter" - } else { - $target = $counter.Value + 1 - } - try { - $headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag } - Invoke-RestMethod -Uri "$counterUrl`?$sasToken" -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null - $buildNumber = $target - Write-Host "Reserved build number ${target} (CAS write ok, attempt $attempt)" - break - } catch { - $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } - if ($status -eq 412) { - Write-Host "CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying" - Start-Sleep -Seconds ($attempt * 2) - } else { - throw - } - } - } - if ($null -eq $buildNumber) { - Write-Error "Could not reserve a build number after 5 CAS attempts" - } + $counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt" + $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override $csproj = Get-Content $env:CSPROJ_PATH -Raw if ($csproj -match '(\d+\.\d+)') { @@ -228,8 +177,13 @@ jobs: Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE" } - # Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above) + # Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above). + # commit/buildUtc are the same two fields the backend and MCP manifests carry — + # additive, and the bootstrap reads this file by field name, so older installers + # ignore them. sha256/bootstrapVersion stay: those are the customer contract. $manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force + $manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force + $manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force if ($bootstrapVersion) { $manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force } diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 7ae4b91e..ba5f8b1a 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -7,6 +7,12 @@ permissions: id-token: write # OIDC federated login (secret-less) contents: read +env: + FUNCTION_APP: autopilotmonitor-api-eu + HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health + VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions + CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj + jobs: build-and-deploy: runs-on: ubuntu-latest @@ -27,17 +33,90 @@ jobs: with: dotnet-version: '10.0.x' + # Same counter mechanism as the agent line: reserve-before-build with ETag-CAS + # against a per-component counter blob. BuildTimestampUtc is pinned here rather + # than left to MSBuild's default so the published manifest and the value baked + # into the assembly (reported by /api/health) are the same instant. + - name: Reserve build number (counter blob CAS) + id: version + shell: pwsh + run: | + $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}' + + $csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw + if ($csproj -notmatch '(\d+\.\d+)') { + throw "VersionPrefix not found in ${{ env.CSPROJ_PATH }}" + } + $version = "$($Matches[1]).$buildNumber" + $buildUtc = [DateTime]::UtcNow.ToString('o') + Write-Host "Backend version: $version (built $buildUtc)" + + "build_number=$buildNumber" >> $env:GITHUB_OUTPUT + "version=$version" >> $env:GITHUB_OUTPUT + "build_utc=$buildUtc" >> $env:GITHUB_OUTPUT + - name: Restore dependencies - run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj + run: dotnet restore ${{ env.CSPROJ_PATH }} - name: Build - run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} + run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }} - name: Publish - run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} + run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }} - name: Deploy to Azure Functions uses: Azure/functions-action@v1 with: - app-name: autopilotmonitor-api-eu + app-name: ${{ env.FUNCTION_APP }} package: ./publish + + # A green deploy step only means the package was accepted. This gate makes the + # workflow assert what is actually being served, so the manifest published below + # can be trusted as "this is live" rather than "this was uploaded". + # /api/health is IP rate-limited at 30/min; one poll per 10s stays well under. + - name: Verify deployed build is live + shell: pwsh + run: | + $expectedCommit = '${{ github.sha }}'.Substring(0, 7) + $expectedVersion = '${{ steps.version.outputs.version }}' + $deadline = [DateTime]::UtcNow.AddMinutes(3) + $attempt = 0 + + while ([DateTime]::UtcNow -lt $deadline) { + $attempt++ + try { + $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30 + if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) { + Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)" + exit 0 + } + Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commitHash) -- waiting for $expectedVersion @ $expectedCommit" + } catch { + Write-Host "Attempt ${attempt}: /api/health not answering yet -- $($_.Exception.Message)" + } + Start-Sleep -Seconds 10 + } + + throw "Backend did not report $expectedVersion @ $expectedCommit within 3 minutes -- deployment not verified, version manifest NOT published" + + # The manifest is the cross-component record of what is deployed where. It is + # written last and only on a verified deploy, so a difference between the + # manifest and /api/health means the running app drifted after the fact + # (rollback, stale instance) rather than a half-finished pipeline. + - name: Publish version manifest + shell: pwsh + run: | + $manifest = [ordered]@{ + component = 'backend' + version = '${{ steps.version.outputs.version }}' + commit = '${{ github.sha }}'.Substring(0, 7) + buildUtc = '${{ steps.version.outputs.build_utc }}' + deployedUtc = [DateTime]::UtcNow.ToString('o') + runId = '${{ github.run_id }}' + } | ConvertTo-Json + + $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?') + $url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas" + Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null + Write-Host "Published versions/backend.json" + Write-Host $manifest diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index 300f4e37..9b350d9d 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -12,6 +12,9 @@ env: CONTAINER_APP_NAME: autopilotmonitor-mcp RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }} IMAGE_TAG: ${{ github.sha }} + HEALTH_URL: https://mcp.autopilotmonitor.com/health + VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions + PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json jobs: build-and-deploy: @@ -44,6 +47,30 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # Same counter mechanism as agent and backend: reserve-before-build with + # ETag-CAS against the component's own counter blob. major.minor stays curated + # in package.json; only the build part is minted here. + - name: Reserve build number (counter blob CAS) + id: version + shell: pwsh + run: | + $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}' + + $pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version + $prefix = ($pkgVersion -split '\.')[0..1] -join '.' + $version = "$prefix.$buildNumber" + $buildUtc = [DateTime]::UtcNow.ToString('o') + Write-Host "MCP version: $version (built $buildUtc)" + + # 7 chars, matching what the backend's BackendBuildInfo reports — one commit + # shape across all components so the values can be compared as-is. + $shortSha = '${{ github.sha }}'.Substring(0, 7) + + "build_number=$buildNumber" >> $env:GITHUB_OUTPUT + "version=$version" >> $env:GITHUB_OUTPUT + "build_utc=$buildUtc" >> $env:GITHUB_OUTPUT + "short_sha=$shortSha" >> $env:GITHUB_OUTPUT + - name: Login to ACR run: az acr login --name ${{ env.ACR_NAME }} @@ -52,6 +79,9 @@ jobs: docker build \ -f src/McpServer/autopilot-monitor-mcp/Dockerfile \ --build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \ + --build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \ + --build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \ + --build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \ -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \ -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \ . @@ -63,3 +93,54 @@ jobs: --name ${{ env.CONTAINER_APP_NAME }} \ --resource-group ${{ env.RESOURCE_GROUP }} \ --image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} + + # `az containerapp update` returns once the revision is provisioned, not once + # it serves traffic — and with minReplicas=0 the first request also pays a cold + # start. Polling /health until it reports THIS build is what makes the manifest + # below mean "live"; a full 5 minutes because the cold start alone is ~25s. + - name: Verify deployed build is live + shell: pwsh + run: | + $expectedCommit = '${{ steps.version.outputs.short_sha }}' + $expectedVersion = '${{ steps.version.outputs.version }}' + $deadline = [DateTime]::UtcNow.AddMinutes(5) + $attempt = 0 + + while ([DateTime]::UtcNow -lt $deadline) { + $attempt++ + try { + $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60 + if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) { + Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)" + exit 0 + } + Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commit) -- waiting for $expectedVersion @ $expectedCommit" + } catch { + Write-Host "Attempt ${attempt}: /health not answering yet -- $($_.Exception.Message)" + } + Start-Sleep -Seconds 15 + } + + throw "MCP did not report $expectedVersion @ $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published" + + # Cross-component record of what is deployed where; written last and only on a + # verified deploy. docsCommit rides along because for this component "which + # build" and "which documentation" are two separate questions. + - name: Publish version manifest + shell: pwsh + run: | + $manifest = [ordered]@{ + component = 'mcp' + version = '${{ steps.version.outputs.version }}' + commit = '${{ steps.version.outputs.short_sha }}' + buildUtc = '${{ steps.version.outputs.build_utc }}' + deployedUtc = [DateTime]::UtcNow.ToString('o') + runId = '${{ github.run_id }}' + docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7) + } | ConvertTo-Json + + $sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?') + $url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas" + Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } -Body $manifest -ContentType 'application/json' | Out-Null + Write-Host "Published versions/mcp.json" + Write-Host $manifest diff --git a/docs/agent/build-counter-blob.md b/docs/agent/build-counter-blob.md index 13a0a2e8..5eee8eda 100644 --- a/docs/agent/build-counter-blob.md +++ b/docs/agent/build-counter-blob.md @@ -22,26 +22,21 @@ attestation on top. # Schema -- **Counter blob**: `https://autopilotmonitor.blob.core.windows.net/agent/buildcounter-v{LINE}.txt` - (per release line, analogous to `version-v{LINE}.json`). Content: the **last used** - build number as plain text. Public-read like the rest of the container (the number - is visible in `version-v{LINE}.json` anyway); writes require the container SAS. -- **Protocol — reserve before build, ETag-CAS**: - 1. Anonymous `GET` the blob, capture value `N` and the `ETag`. - 2. `PUT N+1` with `If-Match: ` (quoted form — pwsh strips the quotes on read, - they must be re-added or the request is rejected client-side). - 3. `412 Precondition Failed` → another build reserved concurrently → re-read, retry - (5 attempts, linear backoff). - 4. Build with `BUILD_NUMBER=N+1` in the environment. The csproj honors - `BUILD_NUMBER` and then skips its `PersistBuildCounter` target, so the repo file - is not touched. -- **Failed builds burn a number.** That is intentional: uniqueness matters, density - does not. Reserve-before-build is what makes concurrent minting impossible. +The reserve-before-build CAS protocol itself is shared with the backend and the MCP +server and is documented once in [Version Contract](../versioning.md); the single +implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific: + +- **Counter blob**: `https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v{LINE}.txt` + — one per release line, analogous to `version-v{LINE}.json`, and kept in the `agent` + container next to the artifacts it numbers rather than in `versions/`. Content: the + **last used** build number as plain text. Public-read like the rest of the container + (the number is visible in `version-v{LINE}.json` anyway); writes require the SAS. +- The reserved number reaches the build as `BUILD_NUMBER` in the environment. The + csproj honors it and then skips its `PersistBuildCounter` target, so the repo file + is not touched. - **CI manual override** (`build_number` input): numbers `<=` counter are re-builds and leave the blob unchanged; numbers `>` counter advance it (monotonic guard). -- **Seeding** (one-time per line): `PUT ` with `If-None-Match: *` - so an existing counter can never be clobbered. V2 was seeded with `1356` on - 2026-07-17. +- V2 was seeded with `1356` on 2026-07-17. # Consequences @@ -62,7 +57,9 @@ attestation on top. # Citations +- [Version Contract](../versioning.md) — the shared CAS protocol and manifest format - `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step -- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — `Request-BuildNumber` +- `.github/scripts/Request-BuildNumber.ps1` — the shared implementation +- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — local build path - `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` — `BUILD_NUMBER` property + `PersistBuildCounter` target diff --git a/docs/index.md b/docs/index.md index 8e270391..126e63a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ https://docs.autopilotmonitor.com (separate repository). # Architecture * [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors. +* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live. # Agent diff --git a/docs/log.md b/docs/log.md index 645ac9b5..459a1f4a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-07-22 +* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`..` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account. + * **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout. * **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests` `[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 00000000..e82c2594 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,108 @@ +--- +type: Concept +title: Version Contract — Agent, Backend, MCP +description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside. +resource: /.github/scripts/Request-BuildNumber.ps1 +tags: + - build + - ci + - release + - versioning +timestamp: 2026-07-22T00:00:00+02:00 +--- + +# Version Contract + +Three components ship from this repository — the agent, the Functions backend and the +MCP server. They share one version shape, one way of minting build numbers, and one +manifest format, so that a version string means the same thing whoever emits it. + +``` +version = .. major.minor curated by hand, build minted per build +commit = 7-char short SHA of this repo +buildUtc = ISO-8601 build moment +``` + +| Component | major.minor from | Counter blob | Reported by | +|---|---|---|---| +| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` | +| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` | +| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake | + +# Schema + +## Build numbers — reserve before build, ETag-CAS + +`.github/scripts/Request-BuildNumber.ps1` is the only implementation; the CI +workflows and the local `build.ps1` all call it. + +1. Anonymous `GET` on the counter blob → value `N` and `ETag`. The blob holds the + **last used** number as plain text and is public-read; only the write needs the + container SAS. +2. `PUT N+1` with `If-Match: `. The quoted ETag form is required — PowerShell + strips the quotes on read, and an unquoted `If-Match` is rejected client-side. +3. `412 Precondition Failed` → a concurrent build reserved first → re-read and retry + (5 attempts, linear backoff). +4. Build with the reserved number. + +Reserving *before* the build is what makes concurrent minting impossible. **A failed +build burns a number** — deliberate: uniqueness matters, density does not. + +Why a counter and not the published manifest: manifests are written on publish, not on +build. The agent writes `version.json` only for stable releases, so deriving the next +number from it would hand two consecutive dev builds the same number and let the second +overwrite the first's artifact. A state-free scheme such as `git rev-list --count HEAD` +fails for the same reason — two builds of one commit collide. + +**Seeding** (once per component): `PUT ` with `If-None-Match: *`, so +an existing counter can never be clobbered. Agent V2 was seeded with `1356` on +2026-07-17; `versions/backend.txt` with `999` and `versions/mcp.txt` with `299` on +2026-07-22. + +## Local builds + +No counter is reached, the build number stays `0`, and the commit is empty or +`unknown`. A `1.5.0` with no commit hash on `/api/health` is therefore a developer +machine, not a release — the fallback is the signal, not a defect. The agent is the +exception: `build.ps1` produces uploadable artifacts and reserves a real number unless +`-SkipUpload` is passed. + +## Manifests + +`versions/backend.json` and `versions/mcp.json` (public-read) record what is deployed: + +```json +{ "component": "backend", "version": "1.5.1000", "commit": "abc1234", + "buildUtc": "…", "deployedUtc": "…", "runId": "…" } +``` + +The MCP manifest adds `docsCommit`, because for that component "which build" and "which +documentation" are separate questions. + +Each is written **last and only after the deploy workflow has polled the component's +health endpoint until it reports that exact version and commit**. A green deploy step +alone means the package was accepted, not that it is being served. Consequently a +manifest that disagrees with the live endpoint means the running instance drifted after +the fact (rollback, stale revision) rather than a half-finished pipeline. + +The agent keeps its own manifest at `agent/version.json` — the bootstrap derives that +URL from the download URL and verifies the ZIP against its `sha256`, so it cannot move. +It carries the same `commit` and `buildUtc` fields; `sha256` and `bootstrapVersion` +remain, being the customer-facing integrity contract. + +# Consequences + +- Bumping `major.minor` is a human decision, made in the component's own file + (`VersionPrefix`, or `version` in `package.json`). Nothing bumps it automatically. +- A new component joins by seeding a counter blob and calling the same script; no new + mechanism. +- The counter blobs and manifests live in the `versions` container on + `autopilotmonitoreu` (writes via `AZURE_BLOB_VERSIONS_SAS_TOKEN`). The agent's + counter stays in the `agent` container next to the artifacts it numbers. + +# Citations + +- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol +- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish +- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line +- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index 61a07e8f..a5c58f64 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -12,8 +12,16 @@ BuildTimestampUtc is baked into the assembly as AssemblyMetadata so /api/health reports the actual build moment — not the file mtime, which Azure Functions resets to the zip-extract time on every cold start. CI may override via - -p:BuildTimestampUtc= to force a specific timestamp. --> - 1.0.0 + -p:BuildTimestampUtc= to force a specific timestamp. + + VersionPrefix is the curated part (bump by hand for a release); BuildNumber is + reserved per build from the counter blob versions/backend.txt and passed in as + -p:BuildNumber= by deploy-backend.yml — same mechanism as the agent line. + A local build has no counter and stays at x.y.0 with an empty commit hash, + which is how /api/health tells a developer machine from a CI artifact. --> + 1.5 + 0 + $(VersionPrefix).$(BuildNumber) true true $([System.DateTime]::UtcNow.ToString('o')) diff --git a/src/McpServer/autopilot-monitor-mcp/Dockerfile b/src/McpServer/autopilot-monitor-mcp/Dockerfile index 512870e4..00319b08 100644 --- a/src/McpServer/autopilot-monitor-mcp/Dockerfile +++ b/src/McpServer/autopilot-monitor-mcp/Dockerfile @@ -89,6 +89,16 @@ COPY --from=build /app/docs/ ./docs/ ARG DOCS_COMMIT=unknown ENV DOCS_COMMIT=${DOCS_COMMIT} +# Build identity of the server itself (deploy-mcp.yml supplies all three). +# BUILD_NUMBER completes the version as ..; left unset in a +# local `docker build`, which keeps package.json's version and marks it as such. +ARG BUILD_NUMBER= +ARG BUILD_COMMIT=unknown +ARG BUILD_UTC=unknown +ENV BUILD_NUMBER=${BUILD_NUMBER} +ENV BUILD_COMMIT=${BUILD_COMMIT} +ENV BUILD_UTC=${BUILD_UTC} + ENV PORT=8080 ENV RULES_DIR=/app/rules ENV DOCS_DIR=/app/docs diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts index b47219e2..aacd6af2 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/index.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts @@ -23,7 +23,21 @@ import { API_BASE_URL } from './config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string }; -const SERVER_VERSION: string = pkg.version; + +// Version contract shared with the agent and the backend: .., +// where major.minor is curated in package.json and is reserved per build +// from the counter blob versions/mcp.txt and baked in by deploy-mcp.yml. Without +// the env var (any local run) the package.json version stands as-is, which is how +// /health and the MCP handshake tell a workstation from a deployed image. +const BUILD_NUMBER = process.env.BUILD_NUMBER ?? ''; +const SERVER_VERSION: string = BUILD_NUMBER + ? `${pkg.version.split('.').slice(0, 2).join('.')}.${BUILD_NUMBER}` + : pkg.version; + +// Commit of THIS repo the image was built from. Counterpart to DOCS_COMMIT below: +// that one tracks the documentation bundle, this one the server itself. +const BUILD_COMMIT = process.env.BUILD_COMMIT ?? 'unknown'; +const BUILD_UTC = process.env.BUILD_UTC ?? 'unknown'; /** * Boot stopwatch. minReplicas=0 means every idle period ends in a cold start a user @@ -299,6 +313,9 @@ app.use(createOAuthRouter()); // The version is the same value already advertised in the MCP handshake // (createMcpServer → { name, version }), so surfacing it here leaks nothing // new; it lets the backend health dashboard show the deployed MCP build. +// commit/buildUtc mirror what the backend's own /api/health has always +// reported (also anonymous): an opaque SHA carries no repository content and +// turns "which build is this?" into one request instead of an image lookup. // // The docs block exists for one reason: the documentation bundle lives in its own // repo and is baked in at image build time, so editing the docs does NOT update a @@ -310,6 +327,8 @@ app.get('/health', (_req, res) => { res.json({ status: 'healthy', version: SERVER_VERSION, + commit: BUILD_COMMIT, + buildUtc: BUILD_UTC, docs: { commit: DOCS_COMMIT, chunks: docsIndex?.size ?? 0, sections: docSections(docsDocs) }, }); });