mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 829ec4c969 | |||
| b2d2d37c6f | |||
| e0ad51b965 | |||
| bbb55034e3 | |||
| 58c8cf87a4 | |||
| 71d2c0e47f | |||
| 3b2f5ed46d | |||
| 7329f12217 | |||
| c9a9df99f1 | |||
| 757427ffea |
+1
-1
Submodule .claude/commands updated: 3ed910ab50...af1b8468c7
@@ -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"
|
||||
@@ -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 '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -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
|
||||
}
|
||||
@@ -277,7 +231,12 @@ jobs:
|
||||
param([string]$Local, [string]$BlobName, [string]$ContentType)
|
||||
$url = "$containerUrl/$BlobName`?$sasToken"
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
$headers = @{ "x-ms-blob-type" = "BlockBlob"; "Content-Type" = $ContentType }
|
||||
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
|
||||
# overwritten each build), and the download alias fronts them with Front Door.
|
||||
# Route caching is disabled there, but a stale manifest paired with a fresh ZIP
|
||||
# (independent cache entries) would fail the bootstrap SHA check -- this header
|
||||
# keeps that class of bug impossible even if caching is ever re-enabled.
|
||||
$headers = @{ "x-ms-blob-type" = "BlockBlob"; "Content-Type" = $ContentType; "x-ms-blob-cache-control" = "no-cache" }
|
||||
if ($ContentType -eq "application/zip") {
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
name: CI
|
||||
|
||||
# Runs the full test suite on every pull request and every push to main.
|
||||
# The deploy workflows build but do not test — this is the gate that decides
|
||||
# whether a change is allowed to be correct.
|
||||
# Tests, typechecks and drift-checks every push to main and every pull request:
|
||||
# the .NET suites, the web and MCP suites, and a guard that the generated rule
|
||||
# files still match rules/guardrails.json.
|
||||
#
|
||||
# The deploy workflows build but never test, so this is the only place the suites
|
||||
# actually run. It does not gate anything — main is deliberately unprotected, so a
|
||||
# red run reports rather than blocks. Read the result and follow up.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
name: CodeQL
|
||||
|
||||
# Documentation-only changes cannot affect what CodeQL analyzes (C# and
|
||||
# TypeScript), so they are filtered out rather than spending ~10 minutes to
|
||||
# re-report an unchanged result. A commit that touches docs AND code still runs:
|
||||
# GitHub skips only when EVERY changed path matches the filter.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface.
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
|
||||
# is also the backstop if the filters above ever skip too much.
|
||||
- cron: '17 3 * * 1'
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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,91 @@ 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 '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
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"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/backend.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -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,55 @@ 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"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/mcp.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -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: <etag>` (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 <last used number>` 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
|
||||
|
||||
@@ -137,8 +137,17 @@ false` and an arm holds), `EspExiting`, `ImeUserSessionCompleted`,
|
||||
advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
|
||||
1. **Completion gates** (currently only the RealmJoin AND-gate): if RealmJoin was
|
||||
detected and is neither resolved nor timed-out (hard 60-min timeout), completion is
|
||||
*deferred* — the session stays in stage and emits `completion_waiting`.
|
||||
detected and has no terminal outcome yet, completion is *deferred* — the session
|
||||
stays in stage and emits `completion_waiting`. Three terminals open the gate:
|
||||
`Resolved` (DeploymentPhase 110), `FirstDeploymentIncomplete` (phase left the
|
||||
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
|
||||
typically by an interactive logon reclassifying the run as secondary-user; emits
|
||||
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
|
||||
`Timeout` (hard 60-min deadline). The abort rule reads the *persisted*
|
||||
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
|
||||
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
|
||||
evaluates it too. A first observation of 210 does **not** release (RJ may have
|
||||
completed before agent boot and deploy afterwards).
|
||||
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
|
||||
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
|
||||
timeline event with full audit payload.
|
||||
|
||||
@@ -12,6 +12,8 @@ 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.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
|
||||
# Agent
|
||||
|
||||
|
||||
+10
@@ -1,7 +1,17 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
* **Update**: `url-registry.md` — the first release after the alias migration surfaced Front Door caching as a correctness hazard: the alias served the previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) after the origin had rotated, and since manifest and ZIP are independent cache entries, a stale manifest with a fresh ZIP fails the bootstrap SHA-256 check. Route caching on `/agent/*` is now disabled and every blob upload stamps `Cache-Control: no-cache` (CI `Send-Blob`, local `build.ps1`, `versions/*.json` manifests) — all agent-container blobs rotate in place, so nothing there is safely cacheable.
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards.
|
||||
|
||||
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`<major>.<minor>.<build>` + 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/<component>.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.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: Concept
|
||||
title: URL Registry — One Place per Component, Guard-Enforced
|
||||
description: Every well-known own or Microsoft host lives in exactly one registry file per component (Constants.cs, utils/config.ts, src/config.ts); guard tests fail the build on any literal outside them. Includes the agent-download host migration to the download alias.
|
||||
resource: /src/Shared/AutopilotMonitor.Shared/Constants.cs
|
||||
tags:
|
||||
- architecture
|
||||
- urls
|
||||
- ci
|
||||
- convention
|
||||
timestamp: 2026-07-23T00:00:00+02:00
|
||||
---
|
||||
|
||||
# URL Registry
|
||||
|
||||
Every well-known base URL — own hosts (portal, www, docs, download alias, MCP,
|
||||
API, both blob accounts) and Microsoft hosts (Graph, Entra login) — is defined
|
||||
in exactly one registry file per component. Code references the constant; the
|
||||
literal appears nowhere else.
|
||||
|
||||
| Component | Registry | Guard test |
|
||||
|---|---|---|
|
||||
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
|
||||
|
||||
Derived registries build on these instead of repeating hosts:
|
||||
`lib/hostRouting.ts` computes `PUBLIC_HOST`/`PORTAL_HOST`/`APEX_HOST` from the
|
||||
URL constants, and `middleware.ts` imports from it.
|
||||
|
||||
# Schema
|
||||
|
||||
The guards scan non-test source for the enforced host list and fail with
|
||||
file:line on any occurrence outside the registry. Comment lines are ignored
|
||||
(docs may cite URLs). Test files are excluded on purpose: a literal in a test
|
||||
is an independent oracle — `ValidateBootstrapCodeResponseShapeTests` spells the
|
||||
full agent download URL out so a registry change must be acknowledged in two
|
||||
places, backend and portal allow-list, consciously.
|
||||
|
||||
Why enforced rather than agreed: the registry existed before (both
|
||||
`Constants.AgentBlobBaseUrl` and the MCP `config.ts` header document earlier
|
||||
drift), yet the EU cutover still missed two literal copies of the blob host —
|
||||
`LatestVersionsService` and `ValidateBootstrapCodeFunction` kept reading the
|
||||
legacy account, silently kept alive only by the fail-soft legacy mirror in
|
||||
`build-agent.yml`. A convention that is not checked decays; these guards make
|
||||
the next stray literal a red build instead of a production surprise.
|
||||
|
||||
# Agent-download host migration (2026-07-23)
|
||||
|
||||
The two legacy readers above plus the agent's `SelfUpdater` now use
|
||||
`Constants.AgentDownloadBaseUrl` (`download.autopilotmonitor.com/agent`, Front
|
||||
Door in front of the current blob origin — verified serving `version.json`,
|
||||
`version-v2.json` and both ZIPs before the switch). The portal's
|
||||
`bootstrapValidation.ts` allow-lists BOTH the alias and the legacy blob host
|
||||
(`AGENT_DOWNLOAD_HOSTNAMES`) because bootstrap scripts already deployed in
|
||||
customer Intune tenants still carry legacy URLs.
|
||||
|
||||
Deploy-order constraint: the web allow-list must be live BEFORE (or with) the
|
||||
backend that serves alias URLs — an old portal build rejects the alias host and
|
||||
the bootstrap flow dies client-side. Remove the legacy hostname from the
|
||||
allow-list only after the legacy account is torn down.
|
||||
|
||||
**Front Door caching is a correctness hazard here, not a staleness nuisance.**
|
||||
The first release after the migration exposed it: the alias kept serving the
|
||||
previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) while the origin was already
|
||||
updated — and because manifest and ZIP are independent cache entries, a stale
|
||||
manifest paired with a fresh ZIP fails the bootstrap's SHA-256 check. Two
|
||||
defenses, both in place since 2026-07-23: route caching on `/agent/*` is
|
||||
DISABLED, and every blob upload (CI `Send-Blob`, local `build.ps1`, the
|
||||
`versions/*.json` manifests) stamps `Cache-Control: no-cache` — all these blobs
|
||||
rotate in place, even the per-line ZIP. If caching is ever re-enabled, the
|
||||
headers keep the mismatch class impossible.
|
||||
|
||||
Deliberately still on the legacy account: the `HealthCheckService` /
|
||||
`MaintenanceService` legacy-keepalive probes and the fail-soft mirror in
|
||||
`build-agent.yml` — they exist precisely to keep already-deployed customer
|
||||
bootstrap scripts working until the customer migration completes.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
|
||||
- `src/Web/autopilot-monitor-web/utils/config.ts` — web registry
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/config.ts` — MCP registry
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/HardcodedUrlGuardTests.cs`
|
||||
- [Version Contract](versioning.md) — the release pipeline this migration protects
|
||||
@@ -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>.<build> 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: <etag>`. 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 <last used number>` 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
|
||||
+1
-1
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
private const string CurrentUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
|
||||
private const string TargetUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
|
||||
|
||||
private static AgentConfigResponse Config(string migrateUrl) =>
|
||||
private static AgentConfigResponse Config(string? migrateUrl) =>
|
||||
new AgentConfigResponse { MigrateToApiBaseUrl = migrateUrl };
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Read the file the production code actually wrote and deserialize with the same lib.
|
||||
var raw = File.ReadAllText(_cachePath);
|
||||
var persisted = JsonConvert.DeserializeObject<AgentConfigResponse>(raw);
|
||||
Assert.NotNull(persisted);
|
||||
|
||||
Assert.False(persisted.UnrestrictedMode);
|
||||
Assert.False(persisted.AllowAgentDowngrade);
|
||||
|
||||
+3
-3
@@ -193,7 +193,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
case BackendBehaviour.AlwaysAuthFailure401:
|
||||
throw new BackendAuthException("Unauthorized", 401);
|
||||
case BackendBehaviour.AlwaysReturnNull:
|
||||
return Task.FromResult<AgentConfigResponse>(null);
|
||||
return Task.FromResult<AgentConfigResponse>(null!);
|
||||
default:
|
||||
throw new InvalidOperationException("unknown behaviour");
|
||||
}
|
||||
@@ -204,9 +204,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
|
||||
// In-memory + per-instance cache so a prior test's successful fetch can't
|
||||
// bleed into a later test that expects UsedDefaults.
|
||||
private AgentConfigResponse _cached;
|
||||
private AgentConfigResponse? _cached;
|
||||
protected override void CacheConfig(AgentConfigResponse config) { _cached = config; }
|
||||
protected override AgentConfigResponse LoadCachedConfig() => _cached;
|
||||
protected override AgentConfigResponse LoadCachedConfig() => _cached!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
logger: logger,
|
||||
helloTracker: null);
|
||||
|
||||
EspExitedEventArgs captured = null;
|
||||
EspExitedEventArgs? captured = null;
|
||||
tracker.EspExited += (_, args) => captured = args;
|
||||
|
||||
// Source time IS in the past relative to the clock — verify the tracker carries the
|
||||
|
||||
+5
-3
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string imeLogPathOverride = null)
|
||||
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
"sess", "tenant",
|
||||
@@ -52,13 +52,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
};
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string> parameters = null) => new GatherRule
|
||||
Dictionary<string, string>? parameters = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-GUARD-TEST",
|
||||
Title = "guard test",
|
||||
CollectorType = collectorType,
|
||||
Target = target,
|
||||
Parameters = parameters,
|
||||
// Match the model default (empty, never null) so the collectors see the
|
||||
// same shape a deserialized rule without parameters gives them.
|
||||
Parameters = parameters ?? new Dictionary<string, string>(),
|
||||
Trigger = "startup",
|
||||
OutputEventType = "guard_test",
|
||||
};
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "1" }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = 1 }));
|
||||
Assert.NotEqual(
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null! }),
|
||||
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "<null>" }));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
}
|
||||
|
||||
private static GatherRule ExitRule(
|
||||
string id, string triggerPhase, List<string> activePhases = null, string fromPhase = null)
|
||||
string id, string triggerPhase, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
|
||||
/// <summary>Interval rule with a 1h period — the timer never ticks within a test run,
|
||||
/// so these rules exist purely for synchronous scope-matrix assertions.</summary>
|
||||
private static GatherRule ScopeProbeRule(string id, List<string> activePhases = null, string fromPhase = null)
|
||||
private static GatherRule ScopeProbeRule(string id, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
ActiveFromPhase = fromPhase,
|
||||
};
|
||||
|
||||
private static GatherRule StartupRule(string id, List<string> activePhases = null, string fromPhase = null)
|
||||
private static GatherRule StartupRule(string id, List<string>? activePhases = null, string? fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[InlineData(" ")] // whitespace only
|
||||
[InlineData("<![LOG[truncated line with no closing")] // truncated / missing fields
|
||||
[InlineData("garbage prefix <![LOG[msg]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"\">")] // leading junk (non-ignorable) fails the StartsWith gate
|
||||
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string line)
|
||||
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string? line)
|
||||
{
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
|
||||
+3
-2
@@ -24,12 +24,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
/// </summary>
|
||||
public sealed class ImeLogPatternReDoSTests
|
||||
{
|
||||
private static ImeLogPattern Pattern(string id, string regex, string action = null) => new ImeLogPattern
|
||||
private static ImeLogPattern Pattern(string id, string regex, string? action = null) => new ImeLogPattern
|
||||
{
|
||||
PatternId = id,
|
||||
Pattern = regex,
|
||||
Category = "always",
|
||||
Action = action,
|
||||
// Action is optional on the wire (absent = null); the model declares it non-nullable.
|
||||
Action = action!,
|
||||
Enabled = true,
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
};
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("0x8007", false)]
|
||||
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string hresult, bool expected)
|
||||
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string? hresult, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AppxFailureAnalyzer.IsAppxFamilyHresult(hresult));
|
||||
}
|
||||
|
||||
+9
-9
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public List<EspFailureDetectedEventArgs> EspFailures { get; } = new List<EspFailureDetectedEventArgs>();
|
||||
public ProvisioningStatusTracker Tracker { get; }
|
||||
|
||||
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>> packageStatesProbe = null)
|
||||
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
{
|
||||
var clock = new VirtualClock(Fixed);
|
||||
var post = new InformationalEventPost(Sink, clock);
|
||||
@@ -179,7 +179,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}");
|
||||
|
||||
var captured = f.CapturedEvents();
|
||||
Assert.Single(captured.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
Assert.Single(captured, e => e.EventType == "esp_failure_settle_started");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -214,8 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// No terminal fire — the failure was retracted.
|
||||
Assert.Empty(f.EspFailures);
|
||||
|
||||
var recovered = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_recovered"));
|
||||
var recovered = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
|
||||
Assert.Equal("AccountSetup", (string)recovered.Data["category"]);
|
||||
Assert.Equal("Apps", (string)recovered.Data["failedSubcategory"]);
|
||||
Assert.Equal("inProgress", (string)recovered.Data["observedState"]);
|
||||
@@ -280,7 +280,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
f.Tracker.TriggerSettleTimerForTest("AccountSetupCategory.Status");
|
||||
|
||||
Assert.Single(f.EspFailures);
|
||||
Assert.Empty(f.CapturedEvents().Where(e => e.EventType == "esp_failure_settle_recovered"));
|
||||
Assert.DoesNotContain(f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -326,8 +326,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
|
||||
}");
|
||||
|
||||
var settleStarted = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
var settleStarted = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
|
||||
Assert.Equal(1, (int)settleStarted.Data["trackedAppsNotCompletedCount"]);
|
||||
var apps = (List<Dictionary<string, object>>)settleStarted.Data["trackedAppsNotCompleted"];
|
||||
var app = Assert.Single(apps);
|
||||
@@ -354,8 +354,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
""SecurityPoliciesSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Failed""}
|
||||
}");
|
||||
|
||||
var settleStarted = Assert.Single(f.CapturedEvents()
|
||||
.Where(e => e.EventType == "esp_failure_settle_started"));
|
||||
var settleStarted = Assert.Single(
|
||||
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
|
||||
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompleted"));
|
||||
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompletedCount"));
|
||||
}
|
||||
|
||||
+15
-6
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
private readonly FakeQuarantineSink _quarantine = new FakeQuarantineSink();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly DecisionEngine _engine = new DecisionEngine();
|
||||
private DecisionStepProcessor _sut;
|
||||
private DecisionStepProcessor? _sut;
|
||||
|
||||
public DecisionStepProcessorParkedTripwireTests()
|
||||
{
|
||||
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return (step, signal);
|
||||
}
|
||||
|
||||
private FakeSignalIngressSink.PostedSignal FindTripwirePost()
|
||||
private FakeSignalIngressSink.PostedSignal? FindTripwirePost()
|
||||
{
|
||||
foreach (var posted in _sink.Posted)
|
||||
{
|
||||
@@ -121,6 +121,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The tripwire post, asserted present — for the assertions that only run
|
||||
/// after <c>WaitForTripwire()</c> has already confirmed one was emitted.</summary>
|
||||
private FakeSignalIngressSink.PostedSignal RequireTripwirePost()
|
||||
{
|
||||
var post = FindTripwirePost();
|
||||
Assert.NotNull(post);
|
||||
return post!;
|
||||
}
|
||||
|
||||
private int CountTripwirePosts()
|
||||
{
|
||||
var count = 0;
|
||||
@@ -165,9 +174,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Thread.Sleep(SuppressWait);
|
||||
Assert.Equal(1, CountTripwirePosts());
|
||||
|
||||
var post = FindTripwirePost();
|
||||
var post = RequireTripwirePost();
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, post.Kind);
|
||||
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload["stage"]);
|
||||
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload!["stage"]);
|
||||
Assert.Equal("Warning", post.Payload[SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", post.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Contains("esp_final_exit", post.Payload["signalsSeen"]);
|
||||
@@ -222,7 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.True(WaitForTripwire());
|
||||
Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost().Payload["armedDeadlines"]);
|
||||
Assert.Equal(DeadlineNames.ClassifierTick, RequireTripwirePost().Payload!["armedDeadlines"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.True(WaitForTripwire());
|
||||
Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost().Payload["stage"]);
|
||||
Assert.Equal(nameof(SessionStage.AwaitingDesktop), RequireTripwirePost().Payload!["stage"]);
|
||||
}
|
||||
|
||||
// ===================================================================== Dwell semantics
|
||||
|
||||
+2
-2
@@ -256,8 +256,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
RemoteConfigFetchOutcome outcome,
|
||||
int configVersion = 0,
|
||||
int attempts = 1,
|
||||
string failureType = null,
|
||||
string failureMessage = null,
|
||||
string? failureType = null,
|
||||
string? failureMessage = null,
|
||||
int? authStatusCode = null)
|
||||
{
|
||||
// CurrentConfig is set via the protected SetConfig path — easiest:
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
[InlineData("Network connections (1 of 1 added)", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void TryExtractErrorCode_extracts_parenthesised_hex_tail(string statusText, string expected)
|
||||
public void TryExtractErrorCode_extracts_parenthesised_hex_tail(string? statusText, string? expected)
|
||||
{
|
||||
var actual = ProvisioningStatusTracker.TryExtractErrorCode(statusText);
|
||||
Assert.Equal(expected, actual);
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
var all = f.InfoEvents(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(2, all.Count);
|
||||
Assert.Equal("65.00", all[1].Payload!["durationSeconds"]);
|
||||
Assert.Equal("cycle_including_reporting_latency", all[1].Payload["durationBasis"]);
|
||||
Assert.Equal("cycle_including_reporting_latency", all[1].Payload!["durationBasis"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+8
-2
@@ -151,17 +151,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerPhaseChangedFromTest_emits_informational_only_no_decision_signal()
|
||||
public void TriggerPhaseChangedFromTest_emits_typed_signal_and_informational_event()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerPhaseChangedFromTest(prev: 100, curr: 200);
|
||||
|
||||
// Phase change is observability-only. There is no DecisionSignalKind.RealmJoinPhaseChanged.
|
||||
// 1) Typed signal — persists LastDeploymentPhase and drives the aborted-RJ-ESP
|
||||
// detection in the reducer (session 224b2087). Not Detected/Resolved.
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinDetected);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved);
|
||||
var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("RealmJoinWatcher", decision.SourceOrigin);
|
||||
Assert.Equal("200", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
|
||||
Assert.Equal("100", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
|
||||
// 2) Dual-emit informational event keeps the existing timeline entry.
|
||||
var info = f.Ingress.Posted.Single(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using Xunit;
|
||||
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
private static List<BandwidthJobSample> Jobs(params BandwidthJobSample[] jobs)
|
||||
=> new List<BandwidthJobSample>(jobs);
|
||||
|
||||
private static BandwidthJobSample Job(string fileId, long wan, long lan = 0)
|
||||
private static BandwidthJobSample Job(string? fileId, long wan, long lan = 0)
|
||||
=> new BandwidthJobSample { FileId = fileId, WanBytes = wan, LanBytes = lan };
|
||||
|
||||
[Fact]
|
||||
@@ -43,8 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.NotNull(estimate);
|
||||
Assert.Equal(1, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax!.Value, 3);
|
||||
Assert.Equal(6_000_000, estimate.WanBytesObserved);
|
||||
Assert.Equal("10-50", estimate.Bucket);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 3_000_000), Job("b", 3_000_000)));
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -74,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(2, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsMax!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(63), Jobs(Job("a", 106_000_000)));
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(1, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -130,8 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", wan: 6_000_000, lan: 375_000_000)));
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(1000.0, estimate.LanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(1000.0, estimate.LanMbpsP90!.Value, 3);
|
||||
Assert.Equal("10-50", estimate.Bucket); // bucket keyed to WAN, not LAN
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(20, estimate.WanSampleCount);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3); // p90 stays on the steady plateau
|
||||
Assert.Equal(160.0, estimate.WanMbpsMax.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3); // p90 stays on the steady plateau
|
||||
Assert.Equal(160.0, estimate.WanMbpsMax!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -265,7 +265,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
|
||||
var estimate = estimator.TryBuildEstimate();
|
||||
Assert.Equal(1, estimate.WanSampleCount); // only the finite positive sample survived
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
|
||||
Assert.Equal(16.0, estimate.WanMbpsP90!.Value, 3);
|
||||
Assert.Equal(0, estimate.WanBytesObserved);
|
||||
}
|
||||
|
||||
|
||||
@@ -370,7 +370,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
// Download alias, not the legacy blob account: the legacy mirror in the
|
||||
// release pipeline is fail-soft, so it can silently lag behind eu.
|
||||
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
|
||||
@@ -497,7 +499,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileNameForLine(2)}";
|
||||
var zipUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileNameForLine(2)}";
|
||||
|
||||
// Clean up any previous download (fragment from aborted run, or symlink/junction).
|
||||
// Pre-delete + FileMode.CreateNew below: CreateNew refuses to open an existing
|
||||
|
||||
+2
-2
@@ -45,10 +45,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
/// </summary>
|
||||
private static readonly (string Name, string Url)[] MdmEndpoints =
|
||||
{
|
||||
("Entra ID", "https://login.microsoftonline.com"),
|
||||
("Entra ID", Constants.EntraLoginBaseUrl),
|
||||
("Device Registration", "https://enterpriseregistration.windows.net"),
|
||||
("Intune Portal", "https://portal.manage.microsoft.com"),
|
||||
("Microsoft Graph", "https://graph.microsoft.com"),
|
||||
("Microsoft Graph", Constants.GraphBaseUrl),
|
||||
};
|
||||
|
||||
public NetworkChangeDetector(
|
||||
|
||||
@@ -137,7 +137,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
private void OnPhaseChanged(object? sender, RealmJoinPhaseChangedEventArgs e)
|
||||
{
|
||||
// Phase change is observability-only: no DecisionSignalKind, just a timeline event.
|
||||
// Dual-emission like OnDetected/OnResolved: (1) the typed signal persists the phase
|
||||
// into RealmJoinFacts.LastDeploymentPhase (restart-safe) and lets the reducer detect
|
||||
// the aborted-RJ-ESP shape (phase left 100/101 for 200/210 without 110 — session
|
||||
// 224b2087); (2) the InformationalEvent keeps the existing timeline entry.
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = e.CurrentPhase.ToString(),
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = e.PreviousPhase.ToString(),
|
||||
};
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.RealmJoinPhaseChanged,
|
||||
occurredAtUtc: _clock.UtcNow,
|
||||
sourceOrigin: SourceLabel,
|
||||
evidence: BuildEvidence(
|
||||
"realmjoin-phase-changed-v1",
|
||||
$"RealmJoin DeploymentPhase {e.PreviousPhase} -> {e.CurrentPhase}"),
|
||||
payload: payload);
|
||||
|
||||
// Both raw int (deploymentPhase / previousPhase) and human-readable name
|
||||
// (deploymentPhaseName / previousPhaseName) flow through — int stays for downstream
|
||||
// filters / KQL, name fields make the message + UI rows readable without a lookup.
|
||||
|
||||
@@ -241,6 +241,199 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
}
|
||||
|
||||
// ============================================================== Aborted first deployment (session 224b2087)
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_leaving_first_deployment_for_200_releases_gate_with_warning()
|
||||
{
|
||||
// Session 224b2087: interactive logon 17 s after phase 101 → RJ reclassifies the run
|
||||
// as secondary-user deployment and writes 200/210, never 110. The 101 -> 200
|
||||
// transition is impossible in a healthy first deployment, so it releases the gate
|
||||
// with Outcome=FirstDeploymentIncomplete and a Warning timeline entry.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(10),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101",
|
||||
}));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc);
|
||||
Assert.Equal(200, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
|
||||
// Hard-timeout deadline cancelled in state AND as a scheduler effect.
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.CancelDeadline &&
|
||||
e.CancelDeadlineName == DeadlineNames.RealmJoinTimeout);
|
||||
|
||||
// Warning timeline entry names both phases.
|
||||
var warning = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
Assert.Equal("Warning", warning.Parameters!["severity"]);
|
||||
Assert.Equal("101", warning.Parameters["previousPhase"]);
|
||||
Assert.Equal("200", warning.Parameters["deploymentPhase"]);
|
||||
Assert.Contains("110", warning.Parameters["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_within_first_deployment_window_is_bookkeeping_only()
|
||||
{
|
||||
// 100 -> 101 stays inside the first-deployment window — no release, but the phase
|
||||
// fact must advance (it is the restart-safe witness for the abort rule).
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100",
|
||||
}));
|
||||
|
||||
Assert.Equal(101, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Empty(step.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_from_completed_deployment_start_does_not_release()
|
||||
{
|
||||
// Session 6f1959c0 protection: RJ already stood at CompletedDeployment (210) when
|
||||
// the agent booted and only deployed AFTERWARDS (210 -> 200 -> 210). Neither
|
||||
// transition starts from the first-deployment window, so the abort rule must not
|
||||
// fire — this ambiguous shape keeps waiting for 110 / the hard timeout (status quo).
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210" })).NewState;
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "210",
|
||||
})).NewState;
|
||||
Assert.Null(state.RealmJoinFacts.Outcome);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(7),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "200",
|
||||
}));
|
||||
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detected_replay_after_restart_with_deployment_phase_releases_gate()
|
||||
{
|
||||
// Agent restarted between the 101 and 200 observations: the watcher re-fires
|
||||
// Detected with the CURRENT phase (200) and no PhaseChanged ever carries the
|
||||
// transition. The persisted LastDeploymentPhase=101 is the witness — the Detected
|
||||
// replay path must release the gate exactly like the PhaseChanged path.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
Assert.Equal(101, state.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(12),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_message_reports_persisted_last_phase_instead_of_zero()
|
||||
{
|
||||
// Before RealmJoinPhaseChanged became a typed signal, LastDeploymentPhase was only
|
||||
// written at detection time — every realmjoin_timeout claimed "last phase: 0" no
|
||||
// matter how far RJ actually got, hiding this whole failure class from ops.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DesktopArrived, T0.AddMinutes(7))).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
Assert.Contains("last phase: 101", timeout.Parameters!["message"]);
|
||||
Assert.Equal("101", timeout.Parameters["lastSeenPhase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfDeploying_deferred_terminal_released_by_first_deployment_incomplete()
|
||||
{
|
||||
// Same deferred-release contract as RealmJoinResolved: when the SelfDeploying
|
||||
// terminal was deferred on the closed RJ gate, the aborted-first-deployment release
|
||||
// must complete the session directly (Completed + enrollment_complete), with the
|
||||
// Warning entry leading the effects.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("rj-sd-3", "rj-tenant", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState;
|
||||
Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(10),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101",
|
||||
}));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
}
|
||||
|
||||
// ============================================================== SelfDeploying flow
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -113,9 +113,11 @@ public class EnrollmentTimeoutClassifierTests
|
||||
// Negative Hello terminals leave the agent waiting — must NOT count as resolved.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_provisioning_failed") }).HelloResolved);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_completion_timeout") }).HelloResolved);
|
||||
// Both RealmJoin gate terminals count as resolved (phase 110 or 60-min hard timeout).
|
||||
// All RealmJoin gate terminals count as resolved (phase 110, aborted first
|
||||
// deployment — session 224b2087 — or 60-min hard timeout).
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_resolved") }).RealmJoinResolved);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_timeout") }).RealmJoinResolved);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_first_deployment_incomplete") }).RealmJoinResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the URL registry: every well-known own or Microsoft host must be
|
||||
/// referenced through <c>AutopilotMonitor.Shared.Constants</c>, never as a
|
||||
/// repeated string literal. The EU cutover missed two hardcoded copies of the
|
||||
/// blob host (LatestVersionsService, ValidateBootstrapCodeFunction) precisely
|
||||
/// because they did not go through the registry — this test makes that class
|
||||
/// of drift a build failure instead of a production surprise.
|
||||
///
|
||||
/// Scope: non-test C# source under src/. Comment lines are ignored (docs and
|
||||
/// examples may cite URLs); test projects are excluded because tests use
|
||||
/// literals deliberately as independent oracles.
|
||||
/// </summary>
|
||||
public class HardcodedUrlGuardTests
|
||||
{
|
||||
private static readonly string[] EnforcedHosts =
|
||||
{
|
||||
"portal.autopilotmonitor.com",
|
||||
"www.autopilotmonitor.com",
|
||||
"docs.autopilotmonitor.com",
|
||||
"download.autopilotmonitor.com",
|
||||
"mcp.autopilotmonitor.com",
|
||||
"autopilotmonitor-api-eu.azurewebsites.net",
|
||||
"autopilotmonitor.blob.core.windows.net",
|
||||
"autopilotmonitoreu.blob.core.windows.net",
|
||||
"graph.microsoft.com",
|
||||
"login.microsoftonline.com",
|
||||
};
|
||||
|
||||
/// <summary>Files allowed to carry the literals — the registry itself.</summary>
|
||||
private static readonly string[] RegistryFiles =
|
||||
{
|
||||
Path.Combine("src", "Shared", "AutopilotMonitor.Shared", "Constants.cs"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void WellKnownHosts_OnlyAppearInTheRegistry()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var srcDir = Path.Combine(repoRoot, "src");
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(srcDir, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(repoRoot, file);
|
||||
var normalized = relative.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
// bin/obj artifacts and generated files
|
||||
if (normalized.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
normalized.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
|
||||
continue;
|
||||
|
||||
// Test projects: literals there are deliberate independent oracles.
|
||||
if (normalized.Contains("Tests"))
|
||||
continue;
|
||||
|
||||
if (RegistryFiles.Any(r => normalized.EndsWith(r, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
// Comment approximation: full-line // and /// comments plus the
|
||||
// body lines of XML docs and block comments. A URL after code on
|
||||
// the same line still counts — that is the case being guarded.
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("/*"))
|
||||
continue;
|
||||
|
||||
foreach (var host in EnforcedHosts)
|
||||
{
|
||||
if (trimmed.Contains(host, StringComparison.OrdinalIgnoreCase))
|
||||
violations.Add($"{relative}:{i + 1}: {host}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Hardcoded well-known host(s) found outside the URL registry " +
|
||||
"(use AutopilotMonitor.Shared.Constants.*BaseUrl instead):\n " +
|
||||
string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
+58
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -87,4 +88,61 @@ public class MaintenanceServiceExcessiveEventAutoActionTests
|
||||
// — better to no-op than block/kill on an unintended config.
|
||||
Assert.Null(MaintenanceService.DecideAutoAction(eventCount: 5000, autoMode: mode, autoThreshold: 2500));
|
||||
}
|
||||
|
||||
// ── Status eligibility ────────────────────────────────────────────────────
|
||||
// A device may only be blocked over a session that can still upload. Without this gate
|
||||
// a session that finished days ago with a high EventCount got its device blocked on the
|
||||
// next sweep, long after it stopped sending.
|
||||
|
||||
[Fact]
|
||||
public void IsAutoActionEligible_InProgress_IsEligible()
|
||||
{
|
||||
Assert.True(MaintenanceService.IsAutoActionEligible(SessionStatus.InProgress));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionStatus.Succeeded)]
|
||||
[InlineData(SessionStatus.Failed)]
|
||||
[InlineData(SessionStatus.Incomplete)]
|
||||
[InlineData(SessionStatus.Stalled)]
|
||||
[InlineData(SessionStatus.AwaitingUser)]
|
||||
[InlineData(SessionStatus.Pending)]
|
||||
[InlineData(SessionStatus.Unknown)]
|
||||
public void IsAutoActionEligible_EverythingElse_IsRejected(SessionStatus status)
|
||||
{
|
||||
// Matches the time-window watchdog's `Status eq 'InProgress'` filter, so neither
|
||||
// detector can block a device over a session that no longer sends. Stalled and
|
||||
// AwaitingUser can heal back to InProgress — then the next sweep acts.
|
||||
Assert.False(MaintenanceService.IsAutoActionEligible(status));
|
||||
}
|
||||
|
||||
// ── Status parsing feeding that gate ──────────────────────────────────────
|
||||
// The runaway query projects a subset of columns. SessionStatus.InProgress is ordinal 0,
|
||||
// so an unparsed Status would leave every row looking in-progress and the gate above
|
||||
// silently always-true — these pin the fail-closed mapping.
|
||||
|
||||
[Theory]
|
||||
[InlineData("InProgress", SessionStatus.InProgress)]
|
||||
[InlineData("inprogress", SessionStatus.InProgress)]
|
||||
[InlineData("Succeeded", SessionStatus.Succeeded)]
|
||||
[InlineData("AwaitingUser", SessionStatus.AwaitingUser)]
|
||||
public void ParseStatusForAutoAction_KnownNames_RoundTrip(string stored, SessionStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, TableStorageService.ParseStatusForAutoAction(stored));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)] // column absent from the projection or the row
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Enrolling")] // status name that never existed
|
||||
[InlineData("0")] // Enum.TryParse would map this onto InProgress and open the gate
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("InProgress,Failed")] // flags-style combination is not a real status
|
||||
public void ParseStatusForAutoAction_UnusableInput_IsUnknown(string? stored)
|
||||
{
|
||||
Assert.Equal(SessionStatus.Unknown, TableStorageService.ParseStatusForAutoAction(stored));
|
||||
Assert.False(MaintenanceService.IsAutoActionEligible(TableStorageService.ParseStatusForAutoAction(stored)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) => throw new NotImplementedException();
|
||||
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
|
||||
public Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
|
||||
public Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId) => throw new NotImplementedException();
|
||||
|
||||
+11
-7
@@ -64,22 +64,26 @@ public class ValidateBootstrapCodeResponseShapeTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hardcoded agent download URL in <c>ValidateBootstrapCodeFunction</c> must
|
||||
/// The agent download URL served by <c>ValidateBootstrapCodeFunction</c> must
|
||||
/// satisfy the frontend's host allow-list and path regex.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentDownloadUrl_MatchesFrontendAllowList()
|
||||
{
|
||||
// Keep in sync with ValidateBootstrapCodeFunction.cs — if that literal
|
||||
// changes, both this assertion and bootstrapValidation.ts must be updated.
|
||||
const string HardcodedAgentUrl =
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
|
||||
// Independent oracle, deliberately spelled out: if Constants or the function
|
||||
// change the URL, this literal AND bootstrapValidation.ts (AGENT_DOWNLOAD_HOSTNAMES)
|
||||
// must be updated consciously — that pairing is what keeps portal and backend in sync.
|
||||
const string ExpectedAgentUrl =
|
||||
"https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
Assert.True(Uri.TryCreate(HardcodedAgentUrl, UriKind.Absolute, out var uri),
|
||||
var built = $"{AutopilotMonitor.Shared.Constants.AgentDownloadBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}";
|
||||
Assert.Equal(ExpectedAgentUrl, built);
|
||||
|
||||
Assert.True(Uri.TryCreate(built, UriKind.Absolute, out var uri),
|
||||
"AgentDownloadUrl is not a well-formed absolute URI");
|
||||
|
||||
Assert.Equal(Uri.UriSchemeHttps, uri!.Scheme);
|
||||
Assert.Equal("autopilotmonitor.blob.core.windows.net", uri.Host);
|
||||
Assert.Equal("download.autopilotmonitor.com", uri.Host);
|
||||
Assert.True(AgentDownloadUrlPath.IsMatch(uri.AbsolutePath),
|
||||
$"AgentDownloadUrl path '{uri.AbsolutePath}' does not match the frontend allow-list regex");
|
||||
}
|
||||
|
||||
@@ -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=<iso8601> to force a specific timestamp. -->
|
||||
<Version>1.0.0</Version>
|
||||
-p:BuildTimestampUtc=<iso8601> 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=<n> 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. -->
|
||||
<VersionPrefix>1.5</VersionPrefix>
|
||||
<BuildNumber Condition="'$(BuildNumber)' == ''">0</BuildNumber>
|
||||
<Version>$(VersionPrefix).$(BuildNumber)</Version>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<IncludeSourceRevisionInInformationalVersion>true</IncludeSourceRevisionInInformationalVersion>
|
||||
<BuildTimestampUtc Condition="'$(BuildTimestampUtc)' == ''">$([System.DateTime]::UtcNow.ToString('o'))</BuildTimestampUtc>
|
||||
|
||||
-4
@@ -590,8 +590,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "ExcessiveEventAutoActionMode", config.ExcessiveEventAutoActionMode ?? "Off" },
|
||||
{ "ExcessiveEventAutoActionThreshold", config.ExcessiveEventAutoActionThreshold },
|
||||
{ "ExcessiveEventAutoActionDurationHours", config.ExcessiveEventAutoActionDurationHours },
|
||||
{ "MaxSessionWindowHours", config.MaxSessionWindowHours },
|
||||
{ "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours },
|
||||
{ "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson },
|
||||
{ "ModernDeploymentHarmlessEventIdsJson", config.ModernDeploymentHarmlessEventIdsJson ?? string.Empty },
|
||||
{ "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty },
|
||||
@@ -657,8 +655,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
ExcessiveEventAutoActionMode = entity.GetString("ExcessiveEventAutoActionMode") ?? "Off",
|
||||
ExcessiveEventAutoActionThreshold = entity.GetInt32("ExcessiveEventAutoActionThreshold") ?? 2500,
|
||||
ExcessiveEventAutoActionDurationHours = entity.GetInt32("ExcessiveEventAutoActionDurationHours") ?? 24,
|
||||
MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24,
|
||||
MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12,
|
||||
DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"),
|
||||
ModernDeploymentHarmlessEventIdsJson = entity.GetString("ModernDeploymentHarmlessEventIdsJson"),
|
||||
WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"),
|
||||
|
||||
-3
@@ -69,9 +69,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff)
|
||||
=> _storage.GetAgentSilentSessionsAsync(tenantId, silenceCutoff, hardCutoff);
|
||||
|
||||
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
|
||||
=> _storage.GetExcessiveDataSendersAsync(tenantId, windowCutoff, maxSessionWindowHours);
|
||||
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults)
|
||||
=> _storage.GetLegacyTimeoutFailedSessionsAsync(tenantId, maxResults);
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
@@ -107,7 +108,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
}
|
||||
|
||||
var consentUrl =
|
||||
$"https://login.microsoftonline.com/{Uri.EscapeDataString(requestCtx.TargetTenantId)}/adminconsent" +
|
||||
$"{Constants.EntraLoginBaseUrl}/{Uri.EscapeDataString(requestCtx.TargetTenantId)}/adminconsent" +
|
||||
$"?client_id={Uri.EscapeDataString(validatorClientId)}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
|
||||
$"&state={Uri.EscapeDataString("autopilot-device-validation-enable")}";
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -92,7 +93,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
{
|
||||
Success = true,
|
||||
ShortCode = session.ShortCode,
|
||||
BootstrapUrl = $"https://autopilotmonitor.com/go/{session.ShortCode}",
|
||||
// www directly — the bare apex is only a registrar-level 301 to www,
|
||||
// so pointing there just adds a redirect hop for every bootstrap.
|
||||
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Message = $"Bootstrap session created. Valid for {validityHours} hours."
|
||||
};
|
||||
|
||||
+3
-1
@@ -92,7 +92,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
Success = true,
|
||||
TenantId = session.TenantId,
|
||||
Token = session.Token,
|
||||
AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
// Download alias — the portal's bootstrapValidation.ts allowlists this
|
||||
// host (plus the legacy blob host for transition); keep the two in sync.
|
||||
AgentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}",
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Message = "Bootstrap code validated"
|
||||
};
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -351,7 +352,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
if (startChannels.Count == 0)
|
||||
return;
|
||||
|
||||
var sessionUrl = $"https://portal.autopilotmonitor.com/sessions/{sessionId}";
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{sessionId}";
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
session.DeviceName,
|
||||
session.SerialNumber,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -6,6 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Net;
|
||||
@@ -248,7 +250,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
/// missing/garbage tid here prevents an outbound OIDC-metadata fetch to a bogus authority on
|
||||
/// malformed-token floods. Loss-free — every legitimate Entra token carries a GUID tid.
|
||||
/// </summary>
|
||||
internal static bool IsValidTenantId(string? tid) => Guid.TryParse(tid, out _);
|
||||
internal static bool IsValidTenantId([NotNullWhen(true)] string? tid) => Guid.TryParse(tid, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a token issuer + tenant id to the OIDC authority to fetch signing keys from. Entra v1.0
|
||||
@@ -259,8 +261,8 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
var isV1Token = issuer != null && issuer.Contains("sts.windows.net");
|
||||
return isV1Token
|
||||
? $"https://login.microsoftonline.com/{tenantId}" // v1.0
|
||||
: $"https://login.microsoftonline.com/{tenantId}/v2.0"; // v2.0
|
||||
? $"{Constants.EntraLoginBaseUrl}/{tenantId}" // v1.0
|
||||
: $"{Constants.EntraLoginBaseUrl}/{tenantId}/v2.0"; // v2.0
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -283,7 +285,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
ValidateIssuer = true,
|
||||
IssuerValidator = (iss, token, parameters) =>
|
||||
{
|
||||
if (iss.StartsWith("https://login.microsoftonline.com/") ||
|
||||
if (iss.StartsWith(Constants.EntraLoginBaseUrl + "/") ||
|
||||
iss.StartsWith("https://sts.windows.net/"))
|
||||
{
|
||||
return iss;
|
||||
|
||||
@@ -104,7 +104,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
options =>
|
||||
{
|
||||
// Multi-Tenant Configuration
|
||||
options.Instance = "https://login.microsoftonline.com/";
|
||||
// Fully qualified: Microsoft.Identity.Web also exports a 'Constants' type.
|
||||
options.Instance = AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/";
|
||||
options.TenantId = "organizations"; // Accept tokens from any Azure AD tenant
|
||||
options.ClientId = builder.Configuration["EntraId:ClientId"];
|
||||
|
||||
@@ -117,7 +118,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuers = new[]
|
||||
{
|
||||
"https://login.microsoftonline.com/organizations/v2.0",
|
||||
AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/organizations/v2.0",
|
||||
"https://sts.windows.net/{tenantid}/"
|
||||
},
|
||||
// Temporarily accept Microsoft Graph tokens (used by frontend with User.Read scope)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -119,7 +120,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// Use contains for server-side narrowing, then perform exact match client-side.
|
||||
var escapedSerial = normalizedSerial.Replace("'", "''");
|
||||
var filter = Uri.EscapeDataString($"contains(serialNumber,'{escapedSerial}')");
|
||||
var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=100&$filter={filter}";
|
||||
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=100&$filter={filter}";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -127,7 +127,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
// Parse certificate from Base64
|
||||
var certBytes = Convert.FromBase64String(certificateBase64);
|
||||
var certificate = new X509Certificate2(certBytes);
|
||||
var certificate = X509CertificateLoader.LoadCertificate(certBytes);
|
||||
var thumbprint = certificate.Thumbprint;
|
||||
|
||||
logger?.LogDebug($"Validating certificate: Subject={certificate.Subject}, Issuer={certificate.Issuer}, Thumbprint={thumbprint}");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -132,7 +133,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
|
||||
var graphUrl = Constants.GraphBaseUrl + "/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
|
||||
var response = await graphClient.PostAsync(graphUrl, content);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -118,7 +119,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var escapedSerial = normalizedSerial.Replace("'", "''");
|
||||
var filter = Uri.EscapeDataString($"contains(serialNumber,'{escapedSerial}')");
|
||||
var orderby = Uri.EscapeDataString("preAssociationDateTime desc");
|
||||
var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/tenantAssociatedDevices"
|
||||
var graphUrl = Constants.GraphBaseUrl + "/beta/deviceManagement/tenantAssociatedDevices"
|
||||
+ $"?$top=25&$filter={filter}&$orderby={orderby}";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
@@ -169,7 +170,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// while this POST is in flight, the generation changes and the token we cache below is
|
||||
// tagged with the now-stale value — so readers reject it and re-mint fresh.
|
||||
var genAtStart = CurrentGen(tenantId);
|
||||
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
|
||||
var tokenUrl = $"{Constants.EntraLoginBaseUrl}/{tenantId}/oauth2/v2.0/token";
|
||||
|
||||
// Retry with backoff to handle Azure AD consent propagation delays.
|
||||
// After admin consent is granted, the service principal may not be immediately
|
||||
@@ -196,7 +197,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["client_secret"] = clientSecret,
|
||||
["scope"] = "https://graph.microsoft.com/.default",
|
||||
["scope"] = Constants.GraphBaseUrl + "/.default",
|
||||
["grant_type"] = "client_credentials"
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -176,7 +177,7 @@ namespace AutopilotMonitor.Functions.Services.Analyze
|
||||
return;
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(envelope.TenantId, envelope.SessionId).ConfigureAwait(false);
|
||||
var sessionUrl = $"https://portal.autopilotmonitor.com/sessions/{envelope.SessionId}";
|
||||
var sessionUrl = $"{Constants.PortalBaseUrl}/sessions/{envelope.SessionId}";
|
||||
|
||||
foreach (var (result, rule) in candidates)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +99,7 @@ public static class EmailTemplates
|
||||
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
|
||||
<tr>
|
||||
<td style=""background-color:#2563eb; border-radius:8px;"">
|
||||
<a href=""https://www.autopilotmonitor.com/docs"" target=""_blank""
|
||||
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
|
||||
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
|
||||
View Documentation
|
||||
</a>
|
||||
|
||||
@@ -147,9 +147,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
helloResolved = true;
|
||||
else if (Eq(type, "realmjoin_detected"))
|
||||
realmJoinDetected = true;
|
||||
// Either terminal opens the agent-side RealmJoin gate: phase 110 or the 60-min
|
||||
// hard timeout (both dual-emitted to the timeline by the agent/engine).
|
||||
else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout"))
|
||||
// Any terminal opens the agent-side RealmJoin gate: phase 110, the aborted
|
||||
// first deployment (phase left 100/101 for 200/210 without 110 — session
|
||||
// 224b2087) or the 60-min hard timeout (all emitted to the timeline by the
|
||||
// agent/engine).
|
||||
else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout")
|
||||
|| Eq(type, "realmjoin_first_deployment_incomplete"))
|
||||
realmJoinResolved = true;
|
||||
// Policy facts for the Hello-disabled / User-ESP-skipped completion mirror.
|
||||
// Contradicting observations are resolved pessimistically below (both-seen →
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return;
|
||||
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
|
||||
? $"{Constants.PortalBaseUrl}/sessions/{request.SessionId}"
|
||||
: null;
|
||||
|
||||
// A failure alert requires an actual failure-ish verdict: an agent_timeout
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
@@ -37,7 +38,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan TokenAcquireBudget = TimeSpan.FromMilliseconds(1500);
|
||||
|
||||
private const string GraphBetaBase = "https://graph.microsoft.com/beta";
|
||||
private const string GraphBetaBase = Constants.GraphBaseUrl + "/beta";
|
||||
private const int PageSize = 100;
|
||||
|
||||
private readonly IGraphFeatureDetector _detector;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -27,7 +28,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
public sealed class LatestVersionsService : ILatestVersionsService
|
||||
{
|
||||
public const string VersionJsonUrl = "https://autopilotmonitor.blob.core.windows.net/agent/version.json";
|
||||
// Download alias (Front Door → current blob origin) — NOT the legacy blob
|
||||
// account. The legacy mirror in build-agent.yml is fail-soft, so reading from
|
||||
// it could silently serve a stale version while the release pipeline is green.
|
||||
public const string VersionJsonUrl = Constants.AgentDownloadBaseUrl + "/" + Constants.AgentVersionFileName;
|
||||
private const string CacheKey = "latest-versions";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(12);
|
||||
private static readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -134,7 +134,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
await MarkStalledSessionsAsTimedOutAsync();
|
||||
await DetectExcessiveEventSessionsAsync();
|
||||
await BlockExcessiveDataSendersAsync();
|
||||
await AggregateMetricsWithCatchUpAsync();
|
||||
// Plan §5 PR6 / §16 R14: session retention fanout extracted out of the 2h timer
|
||||
// into the dedicated 12h SessionDeletionMaintenanceFunction so cascade-lifecycle
|
||||
@@ -563,6 +562,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure eligibility gate for the runaway-session auto-action path: only a session that
|
||||
/// can still upload may cost its device a block. Mirrors the time-window watchdog's
|
||||
/// <c>Status eq 'InProgress'</c> filter, so neither detector ever blocks a device over a
|
||||
/// session that stopped sending — without it, a session that finished days ago with a
|
||||
/// high EventCount still gets its device blocked on the next sweep.
|
||||
/// The warn tier deliberately stays status-agnostic: it is forensic and blocks nothing.
|
||||
/// </summary>
|
||||
internal static bool IsAutoActionEligible(SessionStatus status) => status == SessionStatus.InProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Scans every tenant for sessions whose EventCount exceeds the configured warn
|
||||
/// threshold (<see cref="AdminConfiguration.ExcessiveEventCountThreshold"/>) or the
|
||||
@@ -580,6 +589,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// mode mid-run never re-fires the warn.
|
||||
/// </para>
|
||||
/// Both paths skip when their threshold is 0; the whole sweep no-ops when both are off.
|
||||
/// <para>
|
||||
/// This is the ONLY automatic device block. A second, time-window detector used to run
|
||||
/// beside it (AdminConfiguration.MaxSessionWindowHours) and blocked any session whose
|
||||
/// first and last event lay more than N hours apart. It measured span, never volume, so
|
||||
/// it flagged every enrollment that spanned a night — 23-event sessions included — and
|
||||
/// was removed 2026-07-22. Event count is the quantity that actually costs anything;
|
||||
/// if a span-based guard is ever reintroduced it has to gate on volume too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task DetectExcessiveEventSessionsAsync()
|
||||
{
|
||||
@@ -637,6 +654,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var action = DecideAutoAction(session.EventCount, autoMode, autoThreshold);
|
||||
if (action == null) continue;
|
||||
|
||||
if (!IsAutoActionEligible(session.Status))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping auto-action for runaway session {SessionId} (tenant {TenantId}): status {Status} can no longer upload",
|
||||
session.SessionId, tenantId, session.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -645,6 +670,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't re-block an already-blocked device: BlockDeviceAsync would
|
||||
// overwrite BlockedAt/UnblockAt/Reason of the existing block — most
|
||||
// often one the time-window watchdog placed on the same session,
|
||||
// whose reason then silently disappears from the Active Blocks list.
|
||||
var (alreadyBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (alreadyBlocked)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping auto-action for runaway session {SessionId} (tenant {TenantId}): device {SerialNumber} is already blocked",
|
||||
session.SessionId, tenantId, session.SerialNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var reason = $"Auto-action: excessive session events ({session.EventCount} events ≥ threshold {autoThreshold})";
|
||||
await _blockedDeviceService.BlockDeviceAsync(
|
||||
tenantId,
|
||||
@@ -680,108 +718,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects devices that are still actively sending data beyond the configured maximum session
|
||||
/// window and blocks them automatically. Status-independent: uses LastEventAt (written on every
|
||||
/// event batch) combined with StartedAt to identify sessions spanning too long a data window.
|
||||
/// Controlled by AdminConfiguration.MaxSessionWindowHours (0 = disabled).
|
||||
/// </summary>
|
||||
private async Task<int> BlockExcessiveDataSendersAsync()
|
||||
{
|
||||
_logger.LogInformation("Checking for excessive data senders...");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var adminConfig = await _adminConfigurationService.GetConfigurationAsync();
|
||||
if (adminConfig.MaxSessionWindowHours == 0)
|
||||
{
|
||||
_logger.LogInformation("Excessive data sender detection disabled (MaxSessionWindowHours = 0)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var windowCutoff = DateTime.UtcNow.AddHours(-adminConfig.MaxSessionWindowHours);
|
||||
var blockDurationHours = adminConfig.MaintenanceBlockDurationHours;
|
||||
var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync();
|
||||
int totalBlocked = 0;
|
||||
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessions = await _maintenanceRepo.GetExcessiveDataSendersAsync(tenantId, windowCutoff, adminConfig.MaxSessionWindowHours);
|
||||
|
||||
if (sessions.Count == 0)
|
||||
{
|
||||
_logger.LogInformation($"Tenant {tenantId}: No excessive data sender sessions found (window: {adminConfig.MaxSessionWindowHours}h)");
|
||||
continue;
|
||||
}
|
||||
|
||||
int blockedCount = 0;
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
continue;
|
||||
|
||||
var (isBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (isBlocked)
|
||||
continue;
|
||||
|
||||
var reason = $"Excessive data window: session active for >{adminConfig.MaxSessionWindowHours}h " +
|
||||
$"(started {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC, " +
|
||||
$"last event {session.LastEventAt:yyyy-MM-dd HH:mm:ss} UTC)";
|
||||
|
||||
await _blockedDeviceService.BlockDeviceAsync(
|
||||
tenantId,
|
||||
session.SerialNumber,
|
||||
durationHours: blockDurationHours,
|
||||
blockedByEmail: "System.Maintenance",
|
||||
reason: reason,
|
||||
blockedSessionId: session.SessionId);
|
||||
|
||||
blockedCount++;
|
||||
}
|
||||
|
||||
if (blockedCount > 0)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"ExcessiveDataBlock",
|
||||
"Device",
|
||||
$"{blockedCount} devices",
|
||||
"System.Maintenance",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "DevicesBlocked", blockedCount.ToString() },
|
||||
{ "WindowHours", adminConfig.MaxSessionWindowHours.ToString() },
|
||||
{ "BlockDurationHours", blockDurationHours.ToString() }
|
||||
});
|
||||
|
||||
_logger.LogInformation($"Tenant {tenantId}: Blocked {blockedCount} excessive data sender device(s) (window: {adminConfig.MaxSessionWindowHours}h, block: {blockDurationHours}h)");
|
||||
await _opsEventService.RecordExcessiveDataBlockedAsync(tenantId, blockedCount, adminConfig.MaxSessionWindowHours);
|
||||
}
|
||||
|
||||
totalBlocked += blockedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to check excessive data senders for tenant {tenantId}");
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation($"Excessive data sender check completed: {totalBlocked} devices blocked in {sw.ElapsedMilliseconds}ms");
|
||||
return totalBlocked;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check for excessive data senders");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
|
||||
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -167,7 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
},
|
||||
Actions = new List<NotificationAction>
|
||||
{
|
||||
new() { Type = "openUrl", Title = "Open Autopilot Monitor", Url = "https://portal.autopilotmonitor.com" },
|
||||
new() { Type = "openUrl", Title = "Open Autopilot Monitor", Url = Constants.PortalBaseUrl },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,10 +255,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
$"Device {serialNumber} blocked: {reason}",
|
||||
tenantId, blockedBy, new { serialNumber, reason });
|
||||
|
||||
public Task RecordExcessiveDataBlockedAsync(string tenantId, int devicesBlocked, int windowHours)
|
||||
=> WriteAsync(OpsEventCategory.Security, "ExcessiveDataBlocked", OpsEventSeverity.Warning,
|
||||
$"{devicesBlocked} device(s) auto-blocked for excessive data (>{windowHours}h window)",
|
||||
tenantId, "System.Maintenance", new { devicesBlocked, windowHours });
|
||||
// ExcessiveDataBlocked was removed 2026-07-22 together with the time-window detector that
|
||||
// raised it — it blocked on session span alone and fired on ordinary overnight enrollments.
|
||||
// Automatic blocks now come only from the event-count path below.
|
||||
|
||||
public Task RecordVersionBlockedAsync(string pattern, string blockedBy)
|
||||
=> WriteAsync(OpsEventCategory.Security, "VersionBlocked", OpsEventSeverity.Warning,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public class SlaBreachEvaluationService
|
||||
{
|
||||
private const string DashboardUrl = "https://www.autopilotmonitor.com/sla";
|
||||
private const string DashboardUrl = Constants.WebsiteBaseUrl + "/sla";
|
||||
private const int MaxConflictRetries = 4;
|
||||
private const int MinAppInstallSampleSize = 5;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Azure;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
@@ -345,8 +345,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress automated maintenance entries (SessionTimeout, ExcessiveDataBlock,
|
||||
// DataRetentionCleanup, …) from the human-facing audit list. They are written
|
||||
// Suppress automated maintenance entries (SessionTimeout, DataRetentionCleanup, …)
|
||||
// from the human-facing audit list. They are written
|
||||
// by MaintenanceService for traceability but duplicate the OpsEvents stream
|
||||
// and are not actionable as user-attributable audits. Rows stay in storage
|
||||
// and remain reachable via raw table queries.
|
||||
@@ -792,73 +792,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets sessions where the device has been actively sending data for longer than
|
||||
/// <paramref name="maxSessionWindowHours"/>.
|
||||
/// Status-independent: detects excessive data senders regardless of session status.
|
||||
/// Uses LastEventAt (written on every event batch) for the "still active" check.
|
||||
/// Sessions without LastEventAt (predating this field) are not returned.
|
||||
///
|
||||
/// The OData pre-filter narrows candidates to sessions that straddle the cutoff boundary,
|
||||
/// then a post-filter verifies the actual session duration (LastEventAt − StartedAt)
|
||||
/// exceeds the allowed window. This prevents false positives from short sessions that
|
||||
/// merely happen to straddle the cutoff time.
|
||||
/// </summary>
|
||||
public async Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// OData pre-filter: narrow to sessions that straddle the cutoff boundary.
|
||||
// Status eq 'InProgress' → only block devices whose session is still actively
|
||||
// sending data. Completed sessions (Succeeded/Failed) must NOT be re-blocked
|
||||
// even if their LastEventAt is still within the window — they cannot continue
|
||||
// to abuse data transfer. This also defends against ghost-blocks caused by
|
||||
// devices with bad clocks: an agent that submits events with timestamps from
|
||||
// weeks in the past pushes StartedAt back, making the session look long-lived;
|
||||
// but once the session is Succeeded/Failed, no further data flows from it.
|
||||
// IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device
|
||||
// that resumes after weeks in storage looks like an excessive sender (StartedAt old,
|
||||
// LastEventAt recent) but is a legitimate resumption, not abuse.
|
||||
var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
|
||||
var filter = $"PartitionKey eq '{tenantId}' " +
|
||||
$"and Status eq 'InProgress' " +
|
||||
$"and LastEventAt gt datetime'{cutoffStr}Z' " +
|
||||
$"and StartedAt lt datetime'{cutoffStr}Z' " +
|
||||
$"and IsPreProvisioned ne true";
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
|
||||
var maxDuration = TimeSpan.FromHours(maxSessionWindowHours);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
// Post-filter: verify actual session duration exceeds the window.
|
||||
// OData cannot compute date differences, so we check in code.
|
||||
var startedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime;
|
||||
var lastEventAt = entity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
|
||||
if (startedAt.HasValue && lastEventAt.HasValue
|
||||
&& (lastEventAt.Value - startedAt.Value) < maxDuration)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}");
|
||||
return new List<SessionSummary>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all known tenant IDs from the TenantConfiguration table (one "config" row per
|
||||
/// tenant). This replaces an O(corpus) scan of the entire Sessions table — the maintenance
|
||||
|
||||
@@ -3005,6 +3005,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
#region Excessive-Event Detection
|
||||
|
||||
/// <summary>
|
||||
/// Status parse for the runaway-session rows. Deliberately stricter than
|
||||
/// <c>MapToSessionSummary</c>, which reads a missing column as InProgress for legacy rows:
|
||||
/// here a missing, empty, numeric or unknown value maps to <see cref="SessionStatus.Unknown"/>
|
||||
/// so the auto-block/kill gate fails closed and no device is blocked over a session that
|
||||
/// cannot be classified. The two explicit rejections exist because <c>Enum.TryParse</c> is
|
||||
/// lenient in ways that would open the gate: it maps the string "0" straight onto
|
||||
/// InProgress, and it ORs comma-separated names into a single unrelated member.
|
||||
/// </summary>
|
||||
internal static SessionStatus ParseStatusForAutoAction(string? statusString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statusString)) return SessionStatus.Unknown;
|
||||
if (!char.IsLetter(statusString[0])) return SessionStatus.Unknown;
|
||||
if (statusString.Contains(',')) return SessionStatus.Unknown;
|
||||
return Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status)
|
||||
&& Enum.IsDefined(status)
|
||||
? status
|
||||
: SessionStatus.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns sessions in the given tenant whose EventCount exceeds the threshold.
|
||||
/// Projects only the fields needed for the idempotency check — avoids full entity read.
|
||||
@@ -3023,7 +3043,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// SerialNumber + ExcessiveEventsAutoActioned added for the auto-block/kill
|
||||
// path in MaintenanceService — lets us decide and execute in one round-trip
|
||||
// per qualifying session, without a follow-up GetSessionAsync.
|
||||
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "ExcessiveEventsAlerted", "ExcessiveEventsAutoActioned" });
|
||||
// Status is mandatory for that path: SessionStatus.InProgress is ordinal 0, so
|
||||
// an unselected column would leave every row looking in-progress and the
|
||||
// auto-action eligibility gate silently always-true.
|
||||
select: new[] { "PartitionKey", "RowKey", "EventCount", "SerialNumber", "Status", "ExcessiveEventsAlerted", "ExcessiveEventsAutoActioned" });
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
@@ -3033,6 +3056,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SessionId = entity.RowKey,
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
|
||||
Status = ParseStatusForAutoAction(entity.GetString("Status")),
|
||||
ExcessiveEventsAlerted = entity.GetBoolean("ExcessiveEventsAlerted") ?? false,
|
||||
ExcessiveEventsAutoActioned = entity.GetBoolean("ExcessiveEventsAutoActioned") ?? false
|
||||
});
|
||||
|
||||
@@ -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 <major>.<minor>.<build>; 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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
/**
|
||||
* Enforces the URL registry: every well-known own or Microsoft host must be
|
||||
* referenced through src/config.ts, never as a repeated string literal (the
|
||||
* copy-paste drift config.ts was created to end — see its header comment).
|
||||
* Comment lines are ignored; tests are excluded (literals there are deliberate
|
||||
* independent oracles).
|
||||
*/
|
||||
|
||||
const ENFORCED_HOSTS = [
|
||||
'portal.autopilotmonitor.com',
|
||||
'www.autopilotmonitor.com',
|
||||
'docs.autopilotmonitor.com',
|
||||
'download.autopilotmonitor.com',
|
||||
'mcp.autopilotmonitor.com',
|
||||
'autopilotmonitor-api-eu.azurewebsites.net',
|
||||
'autopilotmonitor.blob.core.windows.net',
|
||||
'autopilotmonitoreu.blob.core.windows.net',
|
||||
'graph.microsoft.com',
|
||||
'login.microsoftonline.com',
|
||||
] as const;
|
||||
|
||||
const SRC_ROOT = join(__dirname, '..');
|
||||
|
||||
/** The registry itself — the only file allowed to carry the literals. */
|
||||
const REGISTRY_FILES = ['config.ts'];
|
||||
|
||||
function collectFiles(dir: string, acc: string[]): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === '__tests__' || entry.startsWith('.')) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
collectFiles(full, acc);
|
||||
} else if (/\.ts$/.test(entry) && !/\.test\.ts$/.test(entry)) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('hardcoded URL guard', () => {
|
||||
it('well-known hosts only appear in the registry (src/config.ts)', () => {
|
||||
const files: string[] = [];
|
||||
collectFiles(SRC_ROOT, files);
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const rel = relative(SRC_ROOT, file).replace(/\\/g, '/');
|
||||
if (REGISTRY_FILES.includes(rel)) continue;
|
||||
|
||||
const lines = readFileSync(file, 'utf-8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
|
||||
for (const host of ENFORCED_HOSTS) {
|
||||
if (trimmed.includes(host)) violations.push(`src/${rel}:${i + 1}: ${host}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
violations,
|
||||
'Hardcoded well-known host(s) found outside src/config.ts — import the constant instead:\n ' +
|
||||
violations.join('\n '),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,12 @@ function resolveApiBaseUrl(): string {
|
||||
/** Resolved backend API base URL (no trailing slash). */
|
||||
export const API_BASE_URL = resolveApiBaseUrl();
|
||||
|
||||
/** Entra ID login/token authority host (no trailing slash). */
|
||||
export const ENTRA_LOGIN_BASE_URL = 'https://login.microsoftonline.com';
|
||||
|
||||
/** Published customer documentation — base for citable search_docs links. */
|
||||
export const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
|
||||
|
||||
/**
|
||||
* Parse a positive-integer environment variable, falling back to `fallback`
|
||||
* for missing, non-numeric, or non-positive values. A bare `parseInt` returns
|
||||
|
||||
@@ -23,9 +23,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import type { SearchDocument } from './search-provider.js';
|
||||
|
||||
/** Published base URL of the bundle — used to build a citable link per chunk. */
|
||||
const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
|
||||
import { DOCS_BASE_URL } from './config.js';
|
||||
|
||||
/**
|
||||
* Upper bound on chunk text. MiniLM-L6-v2's 256-token window is roughly 1000-1200
|
||||
|
||||
@@ -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: <major>.<minor>.<build>,
|
||||
// where major.minor is curated in package.json and <build> 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) },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { oauthRateLimit, oauthTokenRateLimit } from './access-guard.js';
|
||||
import { getPublicBaseUrl } from './config.js';
|
||||
import { ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.js';
|
||||
|
||||
const CLIENT_ID: string = (() => {
|
||||
const v = process.env.AUTOPILOT_ENTRA_CLIENT_ID;
|
||||
@@ -32,7 +32,7 @@ const CLIENT_ID: string = (() => {
|
||||
return v;
|
||||
})();
|
||||
const CLIENT_SECRET = process.env.AUTOPILOT_ENTRA_CLIENT_SECRET ?? '';
|
||||
const AUTHORITY = process.env.AUTOPILOT_ENTRA_AUTHORITY ?? 'https://login.microsoftonline.com/organizations';
|
||||
const AUTHORITY = process.env.AUTOPILOT_ENTRA_AUTHORITY ?? `${ENTRA_LOGIN_BASE_URL}/organizations`;
|
||||
const SCOPES = `api://${CLIENT_ID}/access_as_user openid profile offline_access`;
|
||||
|
||||
// Dynamic client registration (RFC 7591) is STATELESS: instead of a
|
||||
|
||||
@@ -115,6 +115,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'realmjoin_phase_changed',
|
||||
'realmjoin_resolved',
|
||||
'realmjoin_timeout',
|
||||
'realmjoin_first_deployment_incomplete',
|
||||
'realmjoin_package_started',
|
||||
'realmjoin_package_completed',
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SearchProvider, DocsSearchBundle } from '../search-provider.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { ALL_EVENT_TYPES } from '../resource-catalog.js';
|
||||
import { DOCS_BASE_URL } from '../config.js';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1230,7 +1231,7 @@ export function registerSearchTools(
|
||||
title: 'Search Product Documentation',
|
||||
description:
|
||||
'PRODUCT DOCUMENTATION SEARCH — the published Autopilot Monitor customer documentation ' +
|
||||
'(docs.autopilotmonitor.com), chunked by section and ranked semantically. ' +
|
||||
`(${DOCS_BASE_URL}), chunked by section and ranked semantically. ` +
|
||||
'Use this for questions about how the PRODUCT works rather than what a specific enrollment did: ' +
|
||||
'setup and onboarding, agent deployment, portal features, roles and permissions, settings, ' +
|
||||
'notifications, network endpoints, security/privacy/data-residency, plans, and troubleshooting guidance. ' +
|
||||
|
||||
@@ -9,11 +9,12 @@ using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// RealmJoin (RJ) deployment-tracking handlers. Plan: tasks/zany-gathering-oasis plan.
|
||||
// The agent posts five new DecisionSignalKinds when it observes the
|
||||
// The agent posts six DecisionSignalKinds when it observes the
|
||||
// HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters key + HKLM\SOFTWARE\RealmJoin\Packages
|
||||
// (and the HKU\<sid>\... user-scope counterpart). Detection arms a 60-min hard timeout
|
||||
// and gates the enrollment-completion AND-gate. Resolution (phase 110) or timeout
|
||||
// releases the gate so the session can complete.
|
||||
// and gates the enrollment-completion AND-gate. Resolution (phase 110), the
|
||||
// aborted-first-deployment release (phase left 100/101 for 200/210 without 110 —
|
||||
// session 224b2087) or the timeout releases the gate so the session can complete.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Hard 60-minute deadline from RJ-detected. Not configurable (per design choice —
|
||||
@@ -25,6 +26,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
public static class RealmJoinPayloadKeys
|
||||
{
|
||||
public const string DeploymentPhase = "deploymentPhase";
|
||||
// Set on RealmJoinPhaseChanged: the phase the watcher observed immediately before
|
||||
// the transition. The reducer prefers the PERSISTED RealmJoinFacts.LastDeploymentPhase
|
||||
// (restart-safe) and only falls back to this process-local value.
|
||||
public const string PreviousPhase = "previousPhase";
|
||||
public const string PackageId = "packageId";
|
||||
public const string DisplayName = "displayName";
|
||||
public const string Version = "version";
|
||||
@@ -107,6 +112,25 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
}
|
||||
builder.RealmJoinFacts = updatedFacts;
|
||||
|
||||
// Replay path (agent restarted between the 101 and 200 observations): the watcher
|
||||
// re-fires Detected with the CURRENT phase and no PhaseChanged ever carries the
|
||||
// transition — the persisted LastDeploymentPhase is the only witness of the aborted
|
||||
// first deployment. Evaluate the abort rule here too so the gate does not stay
|
||||
// closed until the hard timeout.
|
||||
if (alreadyDetected
|
||||
&& phase.HasValue
|
||||
&& IsFirstDeploymentAbortTransition(state.RealmJoinFacts, state.RealmJoinFacts.LastDeploymentPhase?.Value, phase.Value))
|
||||
{
|
||||
return ReleaseFirstDeploymentIncomplete(
|
||||
state: state,
|
||||
signal: signal,
|
||||
builder: builder,
|
||||
nextStep: nextStep,
|
||||
previousPhase: state.RealmJoinFacts.LastDeploymentPhase!.Value,
|
||||
currentPhase: phase.Value,
|
||||
triggerBase: nameof(DecisionSignalKind.RealmJoinDetected));
|
||||
}
|
||||
|
||||
var effects = Array.Empty<DecisionEffect>();
|
||||
if (!alreadyDetected)
|
||||
{
|
||||
@@ -177,7 +201,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinResolved),
|
||||
leadingEffect: cancelEffect);
|
||||
leadingEffects: cancelEffect != null ? new[] { cancelEffect } : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -237,7 +261,156 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}",
|
||||
leadingEffect: timeoutEffect);
|
||||
leadingEffects: new[] { timeoutEffect });
|
||||
}
|
||||
|
||||
// RJ DeploymentPhase enum values relevant to the abort rule (mirrors
|
||||
// RealmJoin.Core.SoftwarePackaging.DeploymentPhase; the agent-side RealmJoinInfo
|
||||
// constants are not referencable from this netstandard shared lib).
|
||||
private const int RjPhaseRunningFirstDeployment = 100;
|
||||
private const int RjPhaseRunningFirstDeploymentAuto = 101;
|
||||
private const int RjPhaseCompletedFirstDeployment = 110;
|
||||
private const int RjPhaseRunningDeployment = 200;
|
||||
private const int RjPhaseCompletedDeployment = 210;
|
||||
|
||||
private static bool IsRealmJoinFirstDeploymentPhase(int phase) =>
|
||||
phase == RjPhaseRunningFirstDeployment || phase == RjPhaseRunningFirstDeploymentAuto;
|
||||
|
||||
/// <summary>
|
||||
/// The aborted-RJ-ESP shape (session 224b2087): the phase left the first-deployment
|
||||
/// window (100/101) for a regular deployment phase (200/210) — a transition that cannot
|
||||
/// occur in a healthy first deployment, whose only exit is CompletedFirstDeployment
|
||||
/// (110). Root cause observed in the RJ logs: an interactive logon lands seconds after
|
||||
/// 101, RJ reclassifies the run as a secondary-user deployment
|
||||
/// (isFirstMachineDeployment=False) and that branch writes 200/210, never 110.
|
||||
/// <para>
|
||||
/// Deliberately NOT triggered by 101 → 0 (RJ service restart may still complete the
|
||||
/// first deployment) nor by a first observation of 210 (RJ already completed before
|
||||
/// the agent booted — session 6f1959c0 starts 210 → 200; releasing there would be
|
||||
/// wrong). <paramref name="previousPhase"/> comes from the persisted
|
||||
/// <see cref="RealmJoinFacts.LastDeploymentPhase"/> where available so the rule
|
||||
/// survives an agent restart between the 101 and 200 observations.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool IsFirstDeploymentAbortTransition(RealmJoinFacts facts, int? previousPhase, int currentPhase) =>
|
||||
facts.DetectedUtc != null
|
||||
&& facts.Outcome == null
|
||||
&& previousPhase.HasValue
|
||||
&& IsRealmJoinFirstDeploymentPhase(previousPhase.Value)
|
||||
&& (currentPhase == RjPhaseRunningDeployment || currentPhase == RjPhaseCompletedDeployment);
|
||||
|
||||
/// <summary>
|
||||
/// Phase-transition observation from the watcher. Always persists the current phase
|
||||
/// into <see cref="RealmJoinFacts.LastDeploymentPhase"/> (restart-safe; also fixes the
|
||||
/// <c>realmjoin_timeout</c> event reporting "last phase: 0" regardless of how far RJ
|
||||
/// actually got). When <see cref="IsFirstDeploymentAbortTransition"/> matches, emits
|
||||
/// the <c>realmjoin_first_deployment_incomplete</c> Warning, cancels the hard-timeout
|
||||
/// deadline and releases the completion gate — for us RJ is finished at that point.
|
||||
/// </summary>
|
||||
private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var currentPhase = TryReadPhase(signal);
|
||||
if (currentPhase == null)
|
||||
{
|
||||
var deadEnd = BumpStepBookkeeping(state, signal);
|
||||
var deadEndTransition = BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: deadEnd.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged),
|
||||
deadEndReason: "realmjoin_phase_changed_missing_phase");
|
||||
return new DecisionStep(deadEnd, deadEndTransition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
// Persisted fact first (survives agent restarts), watcher's process-local value
|
||||
// as fallback for the very first phase observation of a session.
|
||||
var previousPhase = state.RealmJoinFacts.LastDeploymentPhase?.Value
|
||||
?? TryReadInt(signal, RealmJoinPayloadKeys.PreviousPhase);
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithLastPhase(currentPhase.Value, signal.SessionSignalOrdinal);
|
||||
|
||||
if (!IsFirstDeploymentAbortTransition(state.RealmJoinFacts, previousPhase, currentPhase.Value))
|
||||
{
|
||||
var bookkept = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged));
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
return ReleaseFirstDeploymentIncomplete(
|
||||
state: state,
|
||||
signal: signal,
|
||||
builder: builder,
|
||||
nextStep: nextStep,
|
||||
previousPhase: previousPhase!.Value,
|
||||
currentPhase: currentPhase.Value,
|
||||
triggerBase: nameof(DecisionSignalKind.RealmJoinPhaseChanged));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared gate-release for the aborted-first-deployment rule — called from
|
||||
/// <see cref="HandleRealmJoinPhaseChangedV1"/> and from the
|
||||
/// <see cref="HandleRealmJoinDetectedV1"/> replay path (agent restarted between the
|
||||
/// 101 and 200 observations: the watcher re-fires Detected with the CURRENT phase and
|
||||
/// no PhaseChanged ever carries the transition, so the persisted fact is the only
|
||||
/// witness). Mirrors <see cref="HandleRealmJoinResolvedV1"/>'s release shape with a
|
||||
/// Warning timeline entry ahead of the completion effects.
|
||||
/// </summary>
|
||||
private DecisionStep ReleaseFirstDeploymentIncomplete(
|
||||
DecisionState state,
|
||||
DecisionSignal signal,
|
||||
DecisionStateBuilder builder,
|
||||
int nextStep,
|
||||
int previousPhase,
|
||||
int currentPhase,
|
||||
string triggerBase)
|
||||
{
|
||||
builder.CancelDeadline(DeadlineNames.RealmJoinTimeout);
|
||||
builder.RealmJoinFacts = builder.RealmJoinFacts.WithFirstDeploymentIncomplete(
|
||||
signal.OccurredAtUtc, currentPhase, signal.SessionSignalOrdinal);
|
||||
|
||||
var leadingEffects = new List<DecisionEffect>(capacity: 2);
|
||||
var cancelEffect = BuildRealmJoinTimeoutCancelEffectIfArmed(state);
|
||||
if (cancelEffect != null) leadingEffects.Add(cancelEffect);
|
||||
leadingEffects.Add(BuildRealmJoinFirstDeploymentIncompleteEvent(previousPhase, currentPhase));
|
||||
|
||||
return CompleteIfDeferredOrBookkeep(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: triggerBase + ":FirstDeploymentIncomplete",
|
||||
leadingEffects: leadingEffects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>realmjoin_first_deployment_incomplete</c> Warning timeline entry.
|
||||
/// Reducer-owned (like <see cref="BuildRealmJoinTimeoutEvent"/>) — the agent adapter
|
||||
/// only dual-emits the neutral <c>realmjoin_phase_changed</c> observation; the abort
|
||||
/// interpretation lives here where the persisted facts are.
|
||||
/// </summary>
|
||||
private static DecisionEffect BuildRealmJoinFirstDeploymentIncompleteEvent(int previousPhase, int currentPhase)
|
||||
{
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinFirstDeploymentIncomplete,
|
||||
[SignalPayloadKeys.Source] = "DecisionEngine",
|
||||
[SignalPayloadKeys.Severity] = "Warning",
|
||||
[SignalPayloadKeys.Message] =
|
||||
$"RealmJoin left first deployment (phase {previousPhase} -> {currentPhase}) without CompletedFirstDeployment (110) — the RealmJoin ESP likely aborted. Treating the RealmJoin deployment as finished.",
|
||||
["previousPhase"] = previousPhase.ToString(CultureInfo.InvariantCulture),
|
||||
["deploymentPhase"] = currentPhase.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -360,7 +533,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
DecisionStateBuilder preparedBuilder,
|
||||
int nextStepIndex,
|
||||
string trigger,
|
||||
DecisionEffect? leadingEffect)
|
||||
IReadOnlyList<DecisionEffect>? leadingEffects)
|
||||
{
|
||||
var selfDeployingDeferred = state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value == true;
|
||||
var classicReady = state.HelloResolvedUtc != null && state.DesktopArrivedUtc != null;
|
||||
@@ -421,8 +594,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Plan v9 Phase 4 — UI phase coverage: emit FinalizingSetup + Complete phase
|
||||
// declarations BEFORE enrollment_complete so the Web timeline opens both bars
|
||||
// for RJ-deferred-completion just like the direct SelfDeploying-terminal path.
|
||||
var effects = new List<DecisionEffect>(capacity: 4);
|
||||
if (leadingEffect != null) effects.Add(leadingEffect);
|
||||
var effects = new List<DecisionEffect>(capacity: (leadingEffects?.Count ?? 0) + 3);
|
||||
if (leadingEffects != null && leadingEffects.Count > 0) effects.AddRange(leadingEffects);
|
||||
effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup, completedState, trigger + ":SelfDeployingDeferred"));
|
||||
effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.Complete, completedState, trigger + ":SelfDeployingDeferred"));
|
||||
effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred"));
|
||||
@@ -434,19 +607,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
if (classicReady)
|
||||
{
|
||||
// This IS the gate-release path: the RealmJoin gate has just opened (the caller
|
||||
// wrote WithResolved / WithTimeoutOutcome into preparedBuilder), so complete
|
||||
// directly. Note the completion gates read `state` (pre-resolution), where RJ is
|
||||
// still closed — routing this through CompleteThroughFinalizingOrDefer would
|
||||
// re-defer on the very gate we just released. A future second gate that must
|
||||
// re-block here would re-check the *post* state (WDP-v2 follow-up, ARCH-F1).
|
||||
var extra = leadingEffect != null ? new[] { leadingEffect } : null;
|
||||
// wrote WithResolved / WithTimeoutOutcome / WithFirstDeploymentIncomplete into
|
||||
// preparedBuilder), so complete directly. Note the completion gates read `state`
|
||||
// (pre-resolution), where RJ is still closed — routing this through
|
||||
// CompleteThroughFinalizingOrDefer would re-defer on the very gate we just
|
||||
// released. A future second gate that must re-block here would re-check the
|
||||
// *post* state (WDP-v2 follow-up, ARCH-F1).
|
||||
return TransitionToFinalizing(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: preparedBuilder,
|
||||
nextStepIndex: nextStepIndex,
|
||||
trigger: trigger,
|
||||
extraLeadingEffects: extra);
|
||||
extraLeadingEffects: leadingEffects);
|
||||
}
|
||||
|
||||
// Bookkeeping only — defer to the next Hello / Desktop / SelfDeploying signal.
|
||||
@@ -458,10 +631,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStepIndex,
|
||||
trigger: trigger);
|
||||
|
||||
var bookkeepEffects = leadingEffect != null
|
||||
? new[] { leadingEffect }
|
||||
: Array.Empty<DecisionEffect>();
|
||||
return new DecisionStep(newState, transition, bookkeepEffects);
|
||||
return new DecisionStep(newState, transition, MaterializeEffects(leadingEffects));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -143,6 +143,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// ----- RealmJoin deployment tracking (DecisionEngine.RealmJoin.cs) -----
|
||||
(DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal),
|
||||
// Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) —
|
||||
|
||||
@@ -118,5 +118,12 @@ namespace AutopilotMonitor.DecisionCore.Signals
|
||||
RealmJoinTimeout,
|
||||
RealmJoinPackageStarted,
|
||||
RealmJoinPackageCompleted,
|
||||
// Session 224b2087 (2026-07-22): DeploymentPhase transition observed by the watcher.
|
||||
// Promoted from InformationalEvent-only so RealmJoinFacts.LastDeploymentPhase is
|
||||
// persisted across agent restarts AND so the reducer can detect the aborted-RJ-ESP
|
||||
// shape: an interactive logon during the first deployment (100/101) makes RJ reclassify
|
||||
// the run as a secondary-user deployment, which writes 200/210 and NEVER 110 — the
|
||||
// gate would otherwise stay closed until the 60-min hard timeout.
|
||||
RealmJoinPhaseChanged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// <list type="bullet">
|
||||
/// <item>Immutable; <see cref="With"/>-methods return new instances.</item>
|
||||
/// <item><see cref="Packages"/> is capped at <see cref="MaxPackages"/>; overflow is discarded.</item>
|
||||
/// <item><see cref="ResolvedUtc"/> is set once on observing phase 110; later phase readings update <see cref="LastDeploymentPhase"/> only.</item>
|
||||
/// <item><see cref="Outcome"/> is one of <c>"Resolved"</c> / <c>"Timeout"</c>; null while RJ is still running.</item>
|
||||
/// <item><see cref="ResolvedUtc"/> is set once on observing phase 110 (or the aborted-first-deployment release); later phase readings update <see cref="LastDeploymentPhase"/> only.</item>
|
||||
/// <item><see cref="Outcome"/> is one of <c>"Resolved"</c> / <c>"Timeout"</c> / <c>"FirstDeploymentIncomplete"</c>; null while RJ is still running.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class RealmJoinFacts
|
||||
@@ -25,6 +25,11 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
public const string OutcomeResolved = "Resolved";
|
||||
public const string OutcomeTimeout = "Timeout";
|
||||
// Session 224b2087 (2026-07-22): the RJ ESP was aborted (interactive logon during the
|
||||
// first deployment reclassified the run as secondary-user) — DeploymentPhase left
|
||||
// 100/101 for 200/210 without ever writing CompletedFirstDeployment (110). The gate
|
||||
// releases with this distinct outcome so reporting can tell the three cases apart.
|
||||
public const string OutcomeFirstDeploymentIncomplete = "FirstDeploymentIncomplete";
|
||||
|
||||
public static readonly RealmJoinFacts Empty = new RealmJoinFacts(
|
||||
detectedUtc: null,
|
||||
@@ -161,6 +166,27 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminal outcome for the aborted-RJ-ESP case: DeploymentPhase left the
|
||||
/// first-deployment window (100/101) for a regular deployment phase (200/210) without
|
||||
/// ever reaching CompletedFirstDeployment (110). Sets <see cref="ResolvedUtc"/> (this
|
||||
/// IS our resolution moment — RJ counts as finished for the completion gate) with
|
||||
/// <see cref="OutcomeFirstDeploymentIncomplete"/>. No-op when an outcome is already set.
|
||||
/// </summary>
|
||||
public RealmJoinFacts WithFirstDeploymentIncomplete(DateTime utc, int phase, long sourceSignalOrdinal)
|
||||
{
|
||||
if (Outcome != null) return this; // resolved/timeout already wins
|
||||
return new RealmJoinFacts(
|
||||
detectedUtc: DetectedUtc,
|
||||
resolvedUtc: new SignalFact<DateTime>(utc, sourceSignalOrdinal),
|
||||
lastDeploymentPhase: new SignalFact<int>(phase, sourceSignalOrdinal),
|
||||
outcome: new SignalFact<string>(OutcomeFirstDeploymentIncomplete, sourceSignalOrdinal),
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal)
|
||||
{
|
||||
if (LastDeploymentPhase != null && LastDeploymentPhase.Value == phase) return this;
|
||||
|
||||
@@ -82,6 +82,28 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public const string McpServerBaseUrl = "https://mcp.autopilotmonitor.com";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Well-known base URLs — single registry. Every own or Microsoft host used
|
||||
// in code MUST reference these constants instead of repeating the literal;
|
||||
// HardcodedUrlGuardTests enforces this. The EU cutover missed two copies of
|
||||
// the blob host precisely because they did not go through this file.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Customer portal (deep links in notifications and e-mails).</summary>
|
||||
public const string PortalBaseUrl = "https://portal.autopilotmonitor.com";
|
||||
|
||||
/// <summary>Public marketing/product website.</summary>
|
||||
public const string WebsiteBaseUrl = "https://www.autopilotmonitor.com";
|
||||
|
||||
/// <summary>Published customer documentation.</summary>
|
||||
public const string DocsBaseUrl = "https://docs.autopilotmonitor.com";
|
||||
|
||||
/// <summary>Microsoft Graph API root (no version segment).</summary>
|
||||
public const string GraphBaseUrl = "https://graph.microsoft.com";
|
||||
|
||||
/// <summary>Entra ID (Azure AD) login/token authority host (no trailing slash).</summary>
|
||||
public const string EntraLoginBaseUrl = "https://login.microsoftonline.com";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Agent self-update
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -441,6 +463,11 @@ namespace AutopilotMonitor.Shared
|
||||
public const string RealmJoinPhaseChanged = "realmjoin_phase_changed";
|
||||
public const string RealmJoinResolved = "realmjoin_resolved";
|
||||
public const string RealmJoinTimeout = "realmjoin_timeout";
|
||||
// Warning: DeploymentPhase left the first-deployment window (100/101) for 200/210
|
||||
// without CompletedFirstDeployment (110) — the RJ ESP was aborted (typically an
|
||||
// interactive logon during the first deployment reclassified the run as
|
||||
// secondary-user). The completion gate releases at this point.
|
||||
public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete";
|
||||
public const string RealmJoinPackageStarted = "realmjoin_package_started";
|
||||
public const string RealmJoinPackageCompleted = "realmjoin_package_completed";
|
||||
|
||||
@@ -672,9 +699,11 @@ namespace AutopilotMonitor.Shared
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static (string FailureType, string Message) ClassifyEspAppsFailure(
|
||||
string errorCode, int? espTimeoutMinutes)
|
||||
string? errorCode, int? espTimeoutMinutes)
|
||||
{
|
||||
var normalised = string.IsNullOrEmpty(errorCode) ? null : errorCode.ToLowerInvariant();
|
||||
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)], so the compiler can't
|
||||
// see that the else branch is non-null (CS8602) — assert it.
|
||||
var normalised = string.IsNullOrEmpty(errorCode) ? null : errorCode!.ToLowerInvariant();
|
||||
|
||||
if (string.Equals(normalised, DetectionFailureHResult, System.StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
@@ -82,7 +82,6 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null);
|
||||
Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime);
|
||||
Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff);
|
||||
Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours);
|
||||
|
||||
// --- Legacy Reclassification (misclassification audit 2026-07-16) ---
|
||||
/// <summary>
|
||||
|
||||
@@ -111,27 +111,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int ExcessiveEventAutoActionThreshold { get; set; } = 2500;
|
||||
|
||||
/// <summary>
|
||||
/// Duration in hours for the device block created by the auto-action path. Independent
|
||||
/// of <see cref="MaintenanceBlockDurationHours"/> (which governs the time-window
|
||||
/// ExcessiveDataBlocked path) so the operator can tune both autonomously. Default: 24.
|
||||
/// Duration in hours for the device block created by the auto-action path. Default: 24.
|
||||
/// </summary>
|
||||
public int ExcessiveEventAutoActionDurationHours { get; set; } = 24;
|
||||
|
||||
// ===== MAINTENANCE AUTO-BLOCK SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
/// Max active data window in hours for maintenance auto-block detection.
|
||||
/// Sessions with LastEventAt within the last MaxSessionWindowHours AND StartedAt older
|
||||
/// than MaxSessionWindowHours will have their device blocked by the nightly maintenance function.
|
||||
/// 0 = disabled. Default: 24.
|
||||
/// </summary>
|
||||
public int MaxSessionWindowHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Duration in hours for maintenance-triggered device blocks (excessive data senders).
|
||||
/// Default: 12.
|
||||
/// </summary>
|
||||
public int MaintenanceBlockDurationHours { get; set; } = 12;
|
||||
// MaxSessionWindowHours / MaintenanceBlockDurationHours were removed 2026-07-22 with the
|
||||
// time-window auto-block. That detector blocked on session span alone, so every enrollment
|
||||
// that spanned a night qualified — including 23-event sessions. The event-count settings
|
||||
// above are now the only automatic block. Stored rows keep the two columns until their
|
||||
// next write; nothing reads them.
|
||||
|
||||
/// <summary>
|
||||
/// Retention period in days for operational events in the OpsEvents table.
|
||||
@@ -531,9 +519,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
GlobalAdminRateLimitRequestsPerMinute = 600,
|
||||
PlatformStatsBlobSasUrl = string.Empty,
|
||||
CollectorIdleTimeoutMinutes = 15,
|
||||
DesktopDetectorNoCandidateTimeoutMinutes = 10,
|
||||
MaxSessionWindowHours = 24,
|
||||
MaintenanceBlockDurationHours = 12
|
||||
DesktopDetectorNoCandidateTimeoutMinutes = 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ namespace AutopilotMonitor.Shared.Services
|
||||
/// Validates <paramref name="candidate"/> as a migration target and returns the
|
||||
/// normalized base URL (<c>https://{lowercase-host}</c>, no trailing slash) on success.
|
||||
/// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host
|
||||
/// matching <see cref="AllowedHostSuffixes"/>.
|
||||
/// matching <see cref="AllowedHostSuffixes"/>. <paramref name="normalized"/> is non-null
|
||||
/// exactly when this returns <c>true</c> (netstandard2.0 has no usable
|
||||
/// <c>[NotNullWhen]</c>, so callers must check the bool themselves).
|
||||
/// </summary>
|
||||
public static bool TryNormalizeTarget(string candidate, out string normalized)
|
||||
public static bool TryNormalizeTarget(string candidate, out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
|
||||
@@ -77,7 +79,8 @@ namespace AutopilotMonitor.Shared.Services
|
||||
/// differs from <paramref name="currentBaseUrl"/> (both compared in normalized form —
|
||||
/// serving the agent its own current URL is a no-op, not a migration).
|
||||
/// </summary>
|
||||
public static bool IsEffectiveMigration(string candidate, string currentBaseUrl, out string normalizedTarget)
|
||||
public static bool IsEffectiveMigration(
|
||||
string candidate, string currentBaseUrl, out string? normalizedTarget)
|
||||
{
|
||||
normalizedTarget = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About – Real-Time Windows Autopilot Monitoring",
|
||||
@@ -28,7 +29,7 @@ export const metadata: Metadata = {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
description:
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.",
|
||||
url: "https://www.autopilotmonitor.com/about",
|
||||
url: `${SITE_URL}/about`,
|
||||
},
|
||||
twitter: {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
@@ -36,7 +37,7 @@ export const metadata: Metadata = {
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "https://www.autopilotmonitor.com/about",
|
||||
canonical: `${SITE_URL}/about`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
@@ -295,10 +296,10 @@ export default function AboutPage() {
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Explore Further</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<a href="https://docs.autopilotmonitor.com" target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
Documentation →
|
||||
</a>
|
||||
<a href="https://docs.autopilotmonitor.com/changelog/platform-changelog" target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
Changelog →
|
||||
</a>
|
||||
<Link href="/privacy" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
|
||||
@@ -34,10 +34,6 @@ interface AdminConfigContextValue {
|
||||
setCollectorIdleTimeoutMinutes: (value: number) => void;
|
||||
desktopDetectorNoCandidateTimeoutMinutes: number;
|
||||
setDesktopDetectorNoCandidateTimeoutMinutes: (value: number) => void;
|
||||
maxSessionWindowHours: number;
|
||||
setMaxSessionWindowHours: (value: number) => void;
|
||||
maintenanceBlockDurationHours: number;
|
||||
setMaintenanceBlockDurationHours: (value: number) => void;
|
||||
opsEventRetentionDays: number;
|
||||
setOpsEventRetentionDays: (value: number) => void;
|
||||
slaNotificationCooldownHours: number;
|
||||
@@ -138,8 +134,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const [agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson] = useState("");
|
||||
const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15);
|
||||
const [desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes] = useState(10);
|
||||
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
|
||||
const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12);
|
||||
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
|
||||
const [slaNotificationCooldownHours, setSlaNotificationCooldownHours] = useState(24);
|
||||
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
|
||||
@@ -201,8 +195,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setAgentMigrateTenantOverridesJson(data.agentMigrateTenantOverridesJson ?? "");
|
||||
setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15);
|
||||
setDesktopDetectorNoCandidateTimeoutMinutes(data.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
|
||||
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
|
||||
setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12);
|
||||
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
|
||||
setSlaNotificationCooldownHours(data.slaNotificationCooldownHours ?? 24);
|
||||
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
|
||||
@@ -308,8 +300,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
agentMigrateTenantOverridesJson: agentMigrateTenantOverridesJson.trim(),
|
||||
collectorIdleTimeoutMinutes,
|
||||
desktopDetectorNoCandidateTimeoutMinutes,
|
||||
maxSessionWindowHours,
|
||||
maintenanceBlockDurationHours,
|
||||
opsEventRetentionDays,
|
||||
slaNotificationCooldownHours,
|
||||
allowAgentDowngrade,
|
||||
@@ -342,7 +332,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
|
||||
|
||||
// Reset admin config
|
||||
const handleResetAdminConfig = useCallback(() => {
|
||||
@@ -355,8 +345,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
|
||||
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
|
||||
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
|
||||
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
|
||||
setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12);
|
||||
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
|
||||
setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24);
|
||||
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
|
||||
@@ -501,8 +489,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson,
|
||||
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
|
||||
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
|
||||
maxSessionWindowHours, setMaxSessionWindowHours,
|
||||
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
|
||||
opsEventRetentionDays, setOpsEventRetentionDays,
|
||||
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
|
||||
allowAgentDowngrade, setAllowAgentDowngrade,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -8,6 +9,7 @@ import { trackEvent } from "@/lib/appInsights";
|
||||
import { TenantConfiguration } from "./TenantManagementSection";
|
||||
import { TenantSearchSelect } from "./TenantSearchSelect";
|
||||
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
import { firstBlockedSessionId, blockedSessionCount } from "./blockedDeviceHelpers";
|
||||
|
||||
interface ResolvedDevice {
|
||||
sessionId: string;
|
||||
@@ -27,17 +29,16 @@ interface BlockedDevice {
|
||||
durationHours: number;
|
||||
reason?: string;
|
||||
action?: string;
|
||||
/**
|
||||
* Comma-separated sessions this block is scoped to — set by the maintenance auto-block
|
||||
* paths, absent on a manual whole-device block. Drives the serial's deep-link: without a
|
||||
* session there is nothing honest to link to.
|
||||
*/
|
||||
blockedSessionIds?: string | null;
|
||||
}
|
||||
|
||||
interface DeviceBlockSectionProps {
|
||||
tenants: TenantConfiguration[];
|
||||
maxSessionWindowHours: number;
|
||||
setMaxSessionWindowHours: (value: number) => void;
|
||||
maintenanceBlockDurationHours: number;
|
||||
setMaintenanceBlockDurationHours: (value: number) => void;
|
||||
savingConfig: boolean;
|
||||
adminConfigExists: boolean;
|
||||
onSaveAdminConfig: () => Promise<void>;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
@@ -45,13 +46,6 @@ interface DeviceBlockSectionProps {
|
||||
|
||||
export function DeviceBlockSection({
|
||||
tenants,
|
||||
maxSessionWindowHours,
|
||||
setMaxSessionWindowHours,
|
||||
maintenanceBlockDurationHours,
|
||||
setMaintenanceBlockDurationHours,
|
||||
savingConfig,
|
||||
adminConfigExists,
|
||||
onSaveAdminConfig,
|
||||
getAccessToken,
|
||||
setError,
|
||||
setSuccessMessage,
|
||||
@@ -312,60 +306,6 @@ export function DeviceBlockSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Maintenance Auto-Block Settings */}
|
||||
<div className="border border-red-200 dark:border-red-700 rounded-lg p-4 bg-red-50/50 dark:bg-red-900/10">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<svg className="w-4 h-4 text-red-500 dark:text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100">Maintenance Auto-Block Settings</h3>
|
||||
</div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
|
||||
The nightly maintenance function automatically blocks devices that are still actively sending data beyond the configured window. Sessions with a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">LastEventAt</code> timestamp within the window <em>and</em> a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">StartedAt</code> older than the window are flagged – regardless of session status.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Session Window (Hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={168}
|
||||
value={maxSessionWindowHours}
|
||||
onChange={(e) => setMaxSessionWindowHours(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = disabled</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Maintenance Block Duration (Hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={maintenanceBlockDurationHours}
|
||||
onChange={(e) => setMaintenanceBlockDurationHours(parseInt(e.target.value) || 12)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Applied by the maintenance function</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveAdminConfig}
|
||||
disabled={!canMutate || savingConfig || !adminConfigExists}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
|
||||
>
|
||||
{savingConfig ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>
|
||||
) : (
|
||||
<span>Save Maintenance Settings</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block / Kill a device form */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Block or Kill a Device</h3>
|
||||
@@ -563,7 +503,29 @@ export function DeviceBlockSection({
|
||||
<tbody className="divide-y divide-red-100 dark:divide-red-900/30">
|
||||
{blockedDevices.map((d) => (
|
||||
<tr key={`${d.tenantId}:${d.serialNumber}`} className="bg-white dark:bg-gray-800">
|
||||
<td className="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">{d.serialNumber}</td>
|
||||
<td className="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">
|
||||
{(() => {
|
||||
// Maintenance auto-blocks carry the session they were placed for —
|
||||
// that session is what the operator needs to look at. Manual blocks
|
||||
// have none and stay plain text.
|
||||
const sessionId = firstBlockedSessionId(d.blockedSessionIds);
|
||||
if (!sessionId) return d.serialNumber;
|
||||
const count = blockedSessionCount(d.blockedSessionIds);
|
||||
return (
|
||||
<Link
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 dark:text-blue-300 hover:underline"
|
||||
title={count > 1
|
||||
? `Open the first of ${count} blocked sessions (${sessionId})`
|
||||
: `Open blocked session ${sessionId}`}
|
||||
>
|
||||
{d.serialNumber}
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
{!blockListTenantId && (
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
|
||||
@@ -54,7 +54,7 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
],
|
||||
Security: [
|
||||
"DeviceBlocked",
|
||||
"ExcessiveDataBlocked",
|
||||
// ExcessiveDataBlocked removed 2026-07-22 with the time-window auto-block that raised it.
|
||||
// Critical-tier auto-action: emitted when maintenance auto-blocks/kills a device
|
||||
// after its session crosses `excessiveEventAutoActionThreshold`. Dual-register
|
||||
// per memory feedback_ops_event_types_dual_register so operators can wire a
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Helpers for the Active Blocks table's serial-number deep-link. Lives in a `.ts`
|
||||
// file so vitest (which only loads `.test.ts` and doesn't transform JSX) can import
|
||||
// and test them — same split as `opsEventSessionHelpers.ts`.
|
||||
|
||||
/**
|
||||
* First session ID of a session-scoped block, or null when the block is not tied to a
|
||||
* session. Maintenance auto-blocks (time-window and event-count paths) always carry the
|
||||
* session they were placed for; a manual whole-device block carries none, and inventing a
|
||||
* target for it would send the operator to a session that never triggered anything.
|
||||
*/
|
||||
export function firstBlockedSessionId(blockedSessionIds?: string | null): string | null {
|
||||
if (!blockedSessionIds) return null;
|
||||
for (const part of blockedSessionIds.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Number of sessions a block is scoped to — 0 for a whole-device block. */
|
||||
export function blockedSessionCount(blockedSessionIds?: string | null): number {
|
||||
if (!blockedSessionIds) return 0;
|
||||
return blockedSessionIds.split(",").filter((part) => part.trim().length > 0).length;
|
||||
}
|
||||
@@ -5,26 +5,13 @@ import { DeviceBlockSection } from "../../components/DeviceBlockSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
export function SectionDeviceBlock() {
|
||||
const {
|
||||
tenants,
|
||||
maxSessionWindowHours, setMaxSessionWindowHours,
|
||||
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
|
||||
savingConfig, adminConfig,
|
||||
handleSaveAdminConfig, getAccessToken, setError, setSuccessMessage,
|
||||
} = useAdminConfig();
|
||||
const { tenants, getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
<DeviceBlockSection
|
||||
tenants={tenants}
|
||||
maxSessionWindowHours={maxSessionWindowHours}
|
||||
setMaxSessionWindowHours={setMaxSessionWindowHours}
|
||||
maintenanceBlockDurationHours={maintenanceBlockDurationHours}
|
||||
setMaintenanceBlockDurationHours={setMaintenanceBlockDurationHours}
|
||||
savingConfig={savingConfig}
|
||||
adminConfigExists={!!adminConfig}
|
||||
onSaveAdminConfig={handleSaveAdminConfig}
|
||||
getAccessToken={getAccessToken}
|
||||
setError={setError}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AnalyzeRule, RuleForm, EMPTY_FORM, EMPTY_CONDITION, ruleToForm } from "
|
||||
import AnalyzeRuleFormFields from "./components/AnalyzeRuleFormFields";
|
||||
import AnalyzeRuleCard from "./components/AnalyzeRuleCard";
|
||||
import TemplateConfigModal from "./components/TemplateConfigModal";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function AnalyzeRulesPage() {
|
||||
const router = useRouter();
|
||||
@@ -712,7 +713,7 @@ export default function AnalyzeRulesPage() {
|
||||
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Create Custom Analyze Rule</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href="https://docs.autopilotmonitor.com/rules/analyze-rules" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{/* JSON Mode Toggle */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export function WelcomeMessage() {
|
||||
return (
|
||||
@@ -27,7 +28,7 @@ export function WelcomeMessage() {
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Follow the{" "}
|
||||
<a href="https://docs.autopilotmonitor.com/getting-started/portal-setup" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">setup guide</a>
|
||||
<a href={`${DOCS_URL}/getting-started/portal-setup`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">setup guide</a>
|
||||
{" "}to configure your tenant and grant the required permissions.
|
||||
</p>
|
||||
</li>
|
||||
@@ -35,7 +36,7 @@ export function WelcomeMessage() {
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Deploy the{" "}
|
||||
<a href="https://docs.autopilotmonitor.com/getting-started/deploy-the-agent" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">Intune bootstrapper</a>
|
||||
<a href={`${DOCS_URL}/getting-started/deploy-the-agent`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">Intune bootstrapper</a>
|
||||
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
|
||||
</p>
|
||||
</li>
|
||||
@@ -48,7 +49,7 @@ export function WelcomeMessage() {
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href="https://docs.autopilotmonitor.com" target="_blank" rel="noopener noreferrer"
|
||||
href={DOCS_URL} target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-blue-200 flex items-center justify-center shadow-sm">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { delegatedScopedTenantList, upnDomain } from "@/utils/homeTenantScope";
|
||||
import { useDashboardFilters } from "./hooks/useDashboardFilters";
|
||||
import { useDashboardSessions } from "./hooks/useDashboardSessions";
|
||||
import { useDashboardStats } from "./hooks/useDashboardStats";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function Home() {
|
||||
// useSearchParams() in HomeContent requires a Suspense boundary for static prerender.
|
||||
@@ -293,7 +294,7 @@ function HomeContent() {
|
||||
The platform is under active development.{" "}
|
||||
If something looks off, check the{" "}
|
||||
<a
|
||||
href="https://docs.autopilotmonitor.com/changelog/platform-changelog"
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
@@ -302,7 +303,7 @@ function HomeContent() {
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href="https://docs.autopilotmonitor.com/troubleshooting/service-announcements"
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
|
||||
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
|
||||
import { GatherRuleCard } from "./components/GatherRuleCard";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function GatherRulesPage() {
|
||||
const router = useRouter();
|
||||
@@ -499,7 +500,7 @@ export default function GatherRulesPage() {
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Create Custom Rule</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href="https://docs.autopilotmonitor.com/rules/gather-rules" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<JsonModeToggleButtons
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { validateBootstrapResponse } from "@/utils/bootstrapValidation";
|
||||
import { API_URL_PROD, PORTAL_URL } from "@/utils/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const BACKEND_API_URL =
|
||||
process.env.BACKEND_API_URL ?? "https://autopilotmonitor-api-eu.azurewebsites.net";
|
||||
const BACKEND_API_URL = process.env.BACKEND_API_URL ?? API_URL_PROD;
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
@@ -189,7 +189,7 @@ try {
|
||||
}
|
||||
|
||||
Write-Log "===== OOBE Bootstrap Complete ====="
|
||||
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
|
||||
Write-Log "Agent is now monitoring. View progress at ${PORTAL_URL}"
|
||||
Write-Log ""
|
||||
Write-Log "The agent will:"
|
||||
Write-Log " - Monitor enrollment from the very start"
|
||||
@@ -198,7 +198,7 @@ try {
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
|
||||
Write-Host "View enrollment progress at ${PORTAL_URL}" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -14,11 +14,12 @@ import FeedbackBubble from "../components/FeedbackBubble";
|
||||
import { SidebarProvider } from "../contexts/SidebarContext";
|
||||
import { GlobalSidebar } from "../components/GlobalSidebar";
|
||||
import AppInsightsInit from "../components/AppInsightsInit";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.autopilotmonitor.com"),
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
absolute: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
|
||||
default: "AutopilotMonitor",
|
||||
@@ -47,7 +48,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://www.autopilotmonitor.com",
|
||||
url: SITE_URL,
|
||||
siteName: "AutopilotMonitor",
|
||||
title: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
|
||||
description:
|
||||
@@ -60,7 +61,7 @@ export const metadata: Metadata = {
|
||||
"Real-time monitoring and troubleshooting for Windows Autopilot deployments. Track every enrollment phase, detect issues automatically, and resolve failures faster.",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "https://www.autopilotmonitor.com",
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
verification: {
|
||||
google: "qqIx6VoSjaNL-Idu78il6i3n76_ax9OUT44saxaGyac",
|
||||
@@ -98,7 +99,7 @@ const jsonLd = {
|
||||
name: "Oliver Kieselbach",
|
||||
url: "https://www.linkedin.com/in/oliver-kieselbach/",
|
||||
},
|
||||
url: "https://www.autopilotmonitor.com",
|
||||
url: SITE_URL,
|
||||
codeRepository: "https://github.com/okieselbach/Autopilot-Monitor",
|
||||
keywords:
|
||||
"Windows Autopilot, Intune, enrollment monitoring, autopilot troubleshooting, Windows deployment",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { PUBLIC_HOST } from "@/lib/hostRouting";
|
||||
|
||||
export const alt = "AutopilotMonitor – Real-Time Windows Enrollment Monitoring";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
@@ -81,7 +82,7 @@ export default async function Image() {
|
||||
letterSpacing: "1px",
|
||||
}}
|
||||
>
|
||||
www.autopilotmonitor.com
|
||||
{PUBLIC_HOST}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PublicSiteNavbar } from "../components/PublicSiteNavbar";
|
||||
import { AuthGate } from "../components/landing/AuthGate";
|
||||
import { LoginButton } from "../components/landing/LoginButton";
|
||||
import { PlatformStats } from "../components/landing/PlatformStats";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const QUICK_START = [
|
||||
{
|
||||
@@ -818,7 +819,7 @@ export default function LandingPage() {
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Resources</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="https://docs.autopilotmonitor.com" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Documentation</a></li>
|
||||
<li><a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Documentation</a></li>
|
||||
<li><a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Feedback</a></li>
|
||||
<li><a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">GitHub</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function PreviewPage() {
|
||||
const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout, getAccessToken } = useAuth();
|
||||
@@ -157,7 +158,7 @@ export default function PreviewPage() {
|
||||
Please sign out and contact me on LinkedIn or open a GitHub issue to request access to the Private Preview. I check incoming requests regularly and will approve them as quickly as possible if I have enough capacity left.
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 mt-2">
|
||||
In the meantime, you can already review the setup and configuration in the <a href="https://docs.autopilotmonitor.com" target="_blank" rel="noopener noreferrer" className="underline hover:no-underline">documentation</a>.
|
||||
In the meantime, you can already review the setup and configuration in the <a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline hover:no-underline">documentation</a>.
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 mt-2">
|
||||
When you signed-up, sign in again later to view the updated approval status on your dashboard.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
@@ -8,10 +9,10 @@ export const metadata: Metadata = {
|
||||
title: "Privacy Policy | AutopilotMonitor",
|
||||
description:
|
||||
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data.",
|
||||
url: "https://www.autopilotmonitor.com/privacy",
|
||||
url: `${SITE_URL}/privacy`,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "https://www.autopilotmonitor.com/privacy",
|
||||
canonical: `${SITE_URL}/privacy`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_DATA_FLOWS = "https://docs.autopilotmonitor.com/trust/data-flows";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
|
||||
function DocsLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user