mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-03 00:53:30 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b67e444a | |||
| 47b91f76e7 | |||
| 9c6b55a8bc | |||
| f00b475f59 | |||
| f8743dcae0 | |||
| bd94382edb | |||
| 8abd3a8848 | |||
| 43b3008266 | |||
| 2304436b8c | |||
| ad875d4021 | |||
| fd6b68231e | |||
| 88494a312f | |||
| 6901f6f682 | |||
| 82c0b20be1 | |||
| 4fa439cc3c | |||
| b631c63c53 | |||
| e9aaf3db53 | |||
| a37930e87a | |||
| 6bc5d47afe | |||
| 0ebbf87e5b | |||
| fec4bcc6c5 | |||
| 6ce2176aa0 | |||
| 1fe4e6711e | |||
| 61947c416c | |||
| 25c65f1837 | |||
| eb287248c6 | |||
| 738a349710 | |||
| 73525f1d34 | |||
| b7c6b11909 | |||
| 74d489b51e | |||
| dff69b4d73 | |||
| 9c654983d1 | |||
| 0bc82fd719 | |||
| 783b390448 | |||
| 5c75155293 | |||
| 7588286787 | |||
| 0d6797275c | |||
| 17f98f5c56 | |||
| f53fdf86f6 | |||
| b57f9312ce | |||
| 5136d33c2c | |||
| 5589ca42c7 | |||
| 8f9cf6e153 | |||
| eebbc895c8 | |||
| 7d19a71230 | |||
| 77569e0dfb | |||
| 7a457b5d63 | |||
| 1ea366c419 | |||
| bba8a0d6b4 | |||
| 6abe450388 | |||
| fe193268ea | |||
| ef6cf8e3f4 | |||
| e06a687ddd | |||
| 1ee13de7a8 | |||
| 8cb061784e | |||
| 7fd4a4fe62 | |||
| 8929f1c5b4 | |||
| ff04137d0a | |||
| ff8bc7927f | |||
| 8a53c6233f | |||
| f3a4ce6ed8 | |||
| 42dae1b38c | |||
| b58830ddcb | |||
| caac165172 | |||
| d25f2286b4 | |||
| 1dd4a477c5 | |||
| b8312ee0c6 | |||
| d639d9dda9 | |||
| f44e8aadb0 | |||
| e5b7003799 | |||
| 6db3cb9741 | |||
| a213290eb6 | |||
| d4e67cdbb5 | |||
| e4d3e8ed30 | |||
| d4774ed043 | |||
| b9cc515258 | |||
| fef1f54c09 | |||
| 60dc850317 | |||
| da58ac0a27 | |||
| d1d15db4fe | |||
| a3810b74a7 | |||
| 80d50a6d92 | |||
| 5371de3856 | |||
| 483dc3de94 | |||
| 06b69aa0d8 | |||
| 351c8d4b3a | |||
| e704aa7359 | |||
| 622a2437a0 | |||
| 5ceab7e611 | |||
| 8a7cd5ed98 | |||
| 15804e2817 |
+1
-1
Submodule .claude/commands updated: b4fc66f0df...45a18760a0
@@ -105,7 +105,7 @@ jobs:
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
- uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
# Quality gate for the customer-facing PowerShell scripts. Devices execute them as SYSTEM
|
||||
# during enrollment, so a red suite must never ship. It runs in two roles:
|
||||
# * on its own, on every pull request and push to main that changes an input of these
|
||||
# checks (reports, does not block the merge button);
|
||||
# * called as the gates job that BLOCKS every signing or upload of bootstrap code:
|
||||
# publish-scripts.yml, build-agent.yml (stable releases), build-msi.yml. Those runs
|
||||
# show under the caller.
|
||||
#
|
||||
# Both path lists are the checks' complete inputs and stay identical. They cover every
|
||||
# push path of publish-scripts.yml, which leaves the check of a [skip publish] push to
|
||||
# this workflow.
|
||||
#
|
||||
# No concurrency group: it would also bind the runs called by the publishers, and a
|
||||
# cancel there would block a publish.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's custom action; the
|
||||
# contract tests also read the MSI build.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -247,13 +247,19 @@ jobs:
|
||||
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
|
||||
|
||||
- name: Upload unsigned build output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# A stable release publishes the customer scripts as well, so it waits for the gates their
|
||||
# own publish waits for. Next to the build; skipped when nothing goes out as stable.
|
||||
gates:
|
||||
if: inputs.publish_as_stable
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
@@ -263,7 +269,9 @@ jobs:
|
||||
# sign nor write storage, whatever its permissions say.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [reserve, build]
|
||||
needs: [reserve, build, gates]
|
||||
# A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it.
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }}
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -285,7 +293,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download unsigned build output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -726,9 +734,9 @@ jobs:
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Customer-facing scripts: same publisher and same gates (the gates job) as
|
||||
# publish-scripts.yml, so a release lands a set that is consistent with what is
|
||||
# already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
# Signed with the same identity: the device-side loader verifies the publisher
|
||||
# of what it downloads, and the publisher refuses to upload unsigned scripts.
|
||||
@@ -782,13 +790,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -34,6 +34,12 @@ env:
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
|
||||
jobs:
|
||||
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
|
||||
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
|
||||
# of ours is trusted wherever it lands.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
|
||||
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
|
||||
# need the embedded loader to carry a signature of its own.
|
||||
@@ -42,6 +48,7 @@ jobs:
|
||||
# here. The `release` environment is the only subject the code-signing identity trusts.
|
||||
sign-loader:
|
||||
name: Sign bootstrap loader
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -59,22 +66,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
@@ -136,7 +127,7 @@ jobs:
|
||||
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
|
||||
- name: Upload signed loader
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ env.RUNNER_SCRIPT }}
|
||||
@@ -157,7 +148,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download signed loader
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ runner.temp }}/loader
|
||||
@@ -181,28 +172,112 @@ jobs:
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The MSI's action runs only the exact bytes the package embeds: the hash of the
|
||||
# signed loader goes into the command here, so no other signed script of ours runs
|
||||
# in its place on a device.
|
||||
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Embedded loader SHA-256: $loaderSha256"
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
- name: Upload unsigned MSI
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The real chain on a runner, before anything is signed or published: msiexec installs the
|
||||
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
|
||||
# published installer, which finds the deployment marker set here and removes the copy. The
|
||||
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
|
||||
# the published installer to be 2.9 or later, which removes the copy in every outcome.
|
||||
smoke:
|
||||
name: Install on a runner (${{ matrix.os }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
|
||||
- name: Install with the deployment marker set, check the footprint, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
|
||||
$version = $Matches[1]
|
||||
|
||||
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
|
||||
|
||||
function Invoke-Msiexec {
|
||||
param([string]$Verb, [string]$Log)
|
||||
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
|
||||
}
|
||||
function Get-Registration {
|
||||
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
|
||||
}
|
||||
function Show-Tree {
|
||||
param([string]$Path)
|
||||
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
|
||||
}
|
||||
|
||||
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
|
||||
Invoke-Msiexec -Verb i -Log $installLog
|
||||
$registered = Get-Registration
|
||||
if (-not $registered) { throw 'The product is not registered after the install.' }
|
||||
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
|
||||
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
|
||||
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
|
||||
|
||||
# Only the installer removes the copy the MSI put under ProgramData: its absence
|
||||
# proves the action ran the loader and the loader ran the installer.
|
||||
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
|
||||
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
|
||||
}
|
||||
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
|
||||
if ($runFolders.Count -ne 0) {
|
||||
foreach ($folder in $runFolders) {
|
||||
Show-Tree $folder.FullName
|
||||
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
|
||||
}
|
||||
throw 'A loader run folder stays: the loader did not end with exit 0.'
|
||||
}
|
||||
Write-Host 'After the install: nothing under ProgramData or Program Files.'
|
||||
|
||||
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
|
||||
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
|
||||
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
|
||||
Write-Host 'Uninstalled; the registration is gone.'
|
||||
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
|
||||
# `release`, like sign-loader.
|
||||
release:
|
||||
name: Sign and publish MSI
|
||||
needs: build
|
||||
needs: smoke
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -235,7 +310,7 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -367,7 +442,7 @@ jobs:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
@@ -46,7 +46,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -79,7 +78,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -136,7 +134,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -178,7 +176,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -224,7 +222,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -247,7 +245,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -278,7 +276,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -38,7 +38,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -70,7 +69,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
@@ -114,7 +112,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -125,6 +123,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# `git push` needs.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
||||
|
||||
- name: Upload publish output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-publish.tgz
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download publish output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-artifact
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
sha256sum "$archive"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's command line.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -55,7 +55,12 @@ env:
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
#
|
||||
# A push with [skip publish] in one of its commit messages runs neither job and uploads
|
||||
# nothing; the gates' own push trigger, whose paths cover these, still checks it. Never
|
||||
# [skip actions] for that: it drops every check of the push, CI and these gates included.
|
||||
gates:
|
||||
if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]')
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
@@ -145,7 +150,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
+1
-1
Submodule infra updated: 0085deab45...7589909f54
+1
-1
Submodule internal updated: f12178c74c...70adb72c87
@@ -1,56 +1,63 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-001",
|
||||
"title": "Win32 App Detection Script Failure",
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"title": "App Not Detected After Install (0x87D1041C)",
|
||||
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"required": true,
|
||||
"suppressByEvent": {
|
||||
"eventType": "app_install_completed",
|
||||
"joinField": "appId"
|
||||
}
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
|
||||
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix detection rules",
|
||||
"title": "Check the detection rule against an installed device",
|
||||
"steps": [
|
||||
"Review the app's detection rules in Intune",
|
||||
"Verify file/registry paths are correct",
|
||||
"Test detection script manually on a device"
|
||||
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
|
||||
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
|
||||
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
|
||||
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
|
||||
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Store (WinGet) apps",
|
||||
"steps": [
|
||||
"Check the package identifier and the install behavior (System or User) of the app in Intune",
|
||||
"Install the package manually in the same context on a test device and confirm it is reported as installed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
@@ -61,6 +68,8 @@
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
"win32",
|
||||
"winget",
|
||||
"0x87d1041c"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "app_regression",
|
||||
"source": "event_correlation",
|
||||
"eventType": "app_install_completed",
|
||||
"correlateEventType": "app_install_failed",
|
||||
"joinField": "appId",
|
||||
"dataField": "errorPatternId",
|
||||
"operator": "equals",
|
||||
"value": "IME-ERROR-REPORT",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_succeeded",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the app's post-install enforcement state",
|
||||
"steps": [
|
||||
"Note the appId and appName from the rule finding — these identify the exact affected app",
|
||||
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
|
||||
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
|
||||
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
|
||||
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
|
||||
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix detection or installation issues",
|
||||
"steps": [
|
||||
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
|
||||
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
|
||||
"Consider adding a post-install validation script to catch this scenario proactively"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
|
||||
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -27,9 +27,9 @@
|
||||
"signal": "app_with_detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -42,7 +42,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate the detection rule against an installed device",
|
||||
@@ -66,7 +66,7 @@
|
||||
{
|
||||
"title": "Cross-check the agent's per-app event",
|
||||
"steps": [
|
||||
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
|
||||
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
|
||||
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
|
||||
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
|
||||
]
|
||||
@@ -95,10 +95,6 @@
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+38
-131
@@ -3,56 +3,63 @@
|
||||
"rules": [
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-001",
|
||||
"title": "Win32 App Detection Script Failure",
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"title": "App Not Detected After Install (0x87D1041C)",
|
||||
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"required": true,
|
||||
"suppressByEvent": {
|
||||
"eventType": "app_install_completed",
|
||||
"joinField": "appId"
|
||||
}
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
|
||||
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix detection rules",
|
||||
"title": "Check the detection rule against an installed device",
|
||||
"steps": [
|
||||
"Review the app's detection rules in Intune",
|
||||
"Verify file/registry paths are correct",
|
||||
"Test detection script manually on a device"
|
||||
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
|
||||
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
|
||||
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
|
||||
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
|
||||
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Store (WinGet) apps",
|
||||
"steps": [
|
||||
"Check the package identifier and the install behavior (System or User) of the app in Intune",
|
||||
"Install the package manually in the same context on a test device and confirm it is reported as installed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
@@ -63,7 +70,9 @@
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
"win32",
|
||||
"winget",
|
||||
"0x87d1041c"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -541,104 +550,6 @@
|
||||
],
|
||||
"version": "1.2.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "app_regression",
|
||||
"source": "event_correlation",
|
||||
"eventType": "app_install_completed",
|
||||
"correlateEventType": "app_install_failed",
|
||||
"joinField": "appId",
|
||||
"dataField": "errorPatternId",
|
||||
"operator": "equals",
|
||||
"value": "IME-ERROR-REPORT",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_succeeded",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the app's post-install enforcement state",
|
||||
"steps": [
|
||||
"Note the appId and appName from the rule finding — these identify the exact affected app",
|
||||
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
|
||||
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
|
||||
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
|
||||
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
|
||||
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix detection or installation issues",
|
||||
"steps": [
|
||||
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
|
||||
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
|
||||
"Consider adding a post-install validation script to catch this scenario proactively"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Troubleshoot Win32 app installations",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
},
|
||||
{
|
||||
"title": "Intune Management Extension logs",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"win32",
|
||||
"enforcement",
|
||||
"post-install",
|
||||
"correlation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-012",
|
||||
"title": "Enforcement Error Resolved by Successful Detection",
|
||||
@@ -724,7 +635,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -746,9 +657,9 @@
|
||||
"signal": "app_with_detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -761,7 +672,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate the detection rule against an installed device",
|
||||
@@ -785,7 +696,7 @@
|
||||
{
|
||||
"title": "Cross-check the agent's per-app event",
|
||||
"steps": [
|
||||
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
|
||||
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
|
||||
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
|
||||
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
|
||||
]
|
||||
@@ -814,10 +725,6 @@
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+22
-3
@@ -195,7 +195,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -285,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -487,6 +487,25 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_post_install_detection",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -82,7 +82,8 @@
|
||||
"captureAppTypeMsi",
|
||||
"captureAttemptNumber",
|
||||
"captureDetectionResult",
|
||||
"captureExitCodeClass"
|
||||
"captureExitCodeClass",
|
||||
"markPostInstallDetection"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
Generated
+3
-3
@@ -53,9 +53,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -70,8 +70,8 @@ function global:HarnessInvokeRestMethod {
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
|
||||
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
|
||||
return [pscustomobject]@{
|
||||
Status = $global:HarnessOptions.SignatureStatus
|
||||
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
|
||||
@@ -188,8 +188,11 @@ if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'Harness
|
||||
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
|
||||
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
|
||||
|
||||
# The OOBE script arrives as irm | iex, which ignores #Requires.
|
||||
if ($global:HarnessOptions.Iex) {
|
||||
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
|
||||
# ignores #Requires.
|
||||
if ($global:HarnessOptions.CommandText) {
|
||||
Invoke-Expression $global:HarnessOptions.CommandText
|
||||
} elseif ($global:HarnessOptions.Iex) {
|
||||
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
|
||||
} else {
|
||||
& $global:HarnessOptions.Script @args
|
||||
@@ -228,6 +231,7 @@ exit $LASTEXITCODE
|
||||
Root = $root
|
||||
ProgramData = Join-Path $root 'ProgramData'
|
||||
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
|
||||
ProgramFiles = Join-Path $root 'ProgramFiles'
|
||||
Temp = Join-Path $root 'Temp'
|
||||
Server = Join-Path $root 'Server'
|
||||
Outside = Join-Path $root 'Outside'
|
||||
@@ -262,9 +266,11 @@ exit $LASTEXITCODE
|
||||
CatalogValid = $true
|
||||
Elevated = $true
|
||||
Iex = $false
|
||||
CommandText = $null
|
||||
AgentStartFails = $false
|
||||
}
|
||||
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
|
||||
$script:promiseScenario = $Scenario
|
||||
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
|
||||
$settings.SwapDacl = -not $script:isElevated
|
||||
|
||||
@@ -287,6 +293,7 @@ exit $LASTEXITCODE
|
||||
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
@@ -328,6 +335,25 @@ exit $LASTEXITCODE
|
||||
return ($sorted -join "`n")
|
||||
}
|
||||
|
||||
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
|
||||
# ours anywhere else. The scenario's own fixture folders are the only other entries.
|
||||
function Assert-NothingOutsideProgramData {
|
||||
param($Scenario)
|
||||
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
|
||||
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
|
||||
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
|
||||
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
|
||||
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
|
||||
}
|
||||
|
||||
# Every Describe runs this after each test: the last run of the test kept the promise.
|
||||
function Assert-PromiseOfLastRun {
|
||||
if (-not $script:promiseScenario) { return }
|
||||
$scenario = $script:promiseScenario
|
||||
$script:promiseScenario = $null
|
||||
Assert-NothingOutsideProgramData $scenario
|
||||
}
|
||||
|
||||
function Get-StdErrLines {
|
||||
param($Run)
|
||||
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
|
||||
@@ -369,6 +395,7 @@ exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Describe 'loader 1.4' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs and leaves only the agent folder, with its log carried over' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
|
||||
@@ -471,7 +498,7 @@ Describe 'loader 1.4' {
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.8"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
@@ -502,6 +529,7 @@ Describe 'loader 1.4' {
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
@@ -605,6 +633,7 @@ Describe 'installer assigned directly' {
|
||||
}
|
||||
|
||||
Describe 'loader 1.2, frozen' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
@@ -644,6 +673,7 @@ Describe 'loader 1.2, frozen' {
|
||||
}
|
||||
|
||||
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It '<Name> skips and leaves <Left>' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@@ -660,6 +690,26 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
|
||||
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
|
||||
# installer takes it away in every outcome, also when an agent is present.
|
||||
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
|
||||
$s = New-Scenario
|
||||
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
|
||||
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
|
||||
}
|
||||
|
||||
It '<Name> installs' -TestCases @(
|
||||
@@ -678,10 +728,127 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
}
|
||||
|
||||
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
|
||||
# System32: read the copy once, check our signature on those bytes, run those bytes. The
|
||||
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
|
||||
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
|
||||
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
|
||||
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
|
||||
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
|
||||
$script:actionCommand = $Matches[1]
|
||||
# What the build bakes in: the hash of the loader the package embeds, here the one served.
|
||||
$script:loaderSha256 = (Get-FileHash -LiteralPath $script:loaderPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
function Format-MsiAction {
|
||||
param([string]$Text, [string]$InstallFolder)
|
||||
return $Text.Replace('$(var.LoaderSha256)', $script:loaderSha256).Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
|
||||
}
|
||||
|
||||
function Add-MsiCopy {
|
||||
param($Scenario)
|
||||
$msi = Join-Path $Scenario.Base 'Msi'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
function Invoke-MsiAction {
|
||||
param($Scenario, [hashtable]$Options = @{})
|
||||
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
|
||||
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
|
||||
}
|
||||
|
||||
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
|
||||
# Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
|
||||
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
|
||||
@($errors) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'installs, and the copy goes with the emptied agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
}
|
||||
|
||||
It 'leaves nothing behind when only the deployment marker is there' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'removes the copy and leaves the folder of an agent as it is' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Append = $null }
|
||||
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' }; Append = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Append = $null }
|
||||
@{ Case = 'not the loader the package embeds, however validly signed'; Options = @{}; Append = "`r`n# one more line" }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
if ($Append) { [System.IO.File]::AppendAllText((Join-Path $s.Base 'Msi\Start-AutopilotMonitor.ps1'), $Append) }
|
||||
$run = Invoke-MsiAction $s -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'OOBE script, as the backend generates it' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
@@ -733,6 +900,7 @@ Describe 'OOBE script, as the backend generates it' {
|
||||
}
|
||||
|
||||
Describe 'publish pipeline and dev channel' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$script:rendered = Join-Path $TestDrive 'rendered'
|
||||
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
@@ -768,9 +936,9 @@ Describe 'publish pipeline and dev channel' {
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
|
||||
) {
|
||||
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
|
||||
@@ -817,7 +985,7 @@ Describe 'publish pipeline and dev channel' {
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +800,153 @@ Describe 'Remove-AgentBaseOnSkip' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-MsiTransportFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
|
||||
}
|
||||
|
||||
It 'has nothing to do without an agent folder or without the copy' {
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes the copy and keeps an agent folder that holds anything else' {
|
||||
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
|
||||
|
||||
Test-Path $MsiTransportPath | Should -BeFalse
|
||||
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
|
||||
}
|
||||
|
||||
It 'removes the copy and with it an agent folder that is then empty' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
|
||||
Test-Path $AgentBasePath | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'leaves a copy that is not ours, and says so' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'touches nothing below an agent folder that is not ours' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link planted as the copy' {
|
||||
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
|
||||
$target = New-SentinelJunction -Link $MsiTransportPath
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-MsiTransportFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LegacyMsiLoaderFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:realProgramW6432 = $env:ProgramW6432
|
||||
$script:realProgramFiles = $env:ProgramFiles
|
||||
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
|
||||
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
|
||||
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
|
||||
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
}
|
||||
AfterEach {
|
||||
$env:ProgramW6432 = $script:realProgramW6432
|
||||
$env:ProgramFiles = $script:realProgramFiles
|
||||
}
|
||||
|
||||
It 'has nothing to do where MSI 1.3 never ran' {
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
Test-Path $script:company | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes what MSI 1.3 installed, and the company folder with it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'keeps the company folder when anything else is in it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
|
||||
|
||||
Test-Path (Split-Path $script:loader) | Should -BeFalse
|
||||
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'reads the 64-bit Program Files, also from the 32-bit host' {
|
||||
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
New-Item -ItemType File -Path $other -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
Test-Path $other | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'leaves a folder that is not ours' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
|
||||
|
||||
Test-Path $script:loader | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link' {
|
||||
$target = New-SentinelJunction -Link $script:company
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-AgentPayloadRejections' {
|
||||
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
|
||||
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
|
||||
@@ -1122,7 +1269,7 @@ Describe 'script text' {
|
||||
|
||||
It 'declares the role and version the loader checks, one line each' {
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.8"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'never stages anything in the shared temp folder' {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
|
||||
file matches the catalog signed by glueckkanja AG. The agent removes itself when
|
||||
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
|
||||
that the device is not installed again. A skipped device without an agent keeps nothing.
|
||||
that the device is not installed again. A skipped device without an agent keeps nothing,
|
||||
and the loader copy the bootstrap MSI installs is removed in every outcome.
|
||||
|
||||
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
|
||||
or the install fails; a skip is logged to the script output only. Exit code 0: installed
|
||||
@@ -53,7 +54,7 @@ param(
|
||||
)
|
||||
|
||||
# The loader checks these two lines before it runs this script.
|
||||
$ScriptVersion = "2.8"
|
||||
$ScriptVersion = "2.9"
|
||||
$ScriptRole = "AutopilotMonitor-Installer"
|
||||
|
||||
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
|
||||
@@ -69,6 +70,9 @@ $LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
|
||||
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
# Where the bootstrap MSI installs its loader copy; this script removes it in every outcome.
|
||||
$MsiTransportPath = "$AgentBasePath\Msi"
|
||||
|
||||
$AgentExeName = "AutopilotMonitor.Agent.exe"
|
||||
$AgentTaskName = "AutopilotMonitor-Agent"
|
||||
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
|
||||
@@ -608,6 +612,57 @@ function Remove-AgentBaseOnSkip {
|
||||
}
|
||||
}
|
||||
|
||||
# The loader copy the bootstrap MSI installs. It goes whatever the outcome; an agent folder
|
||||
# that is then empty goes with it (a running agent, or logs kept after enrollment, keep it).
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-MsiTransportFolder {
|
||||
try {
|
||||
if ((Get-FolderState $AgentBasePath) -ne 'Ours') { return $null }
|
||||
$state = Get-FolderState $MsiTransportPath
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$MsiTransportPath left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $MsiTransportPath
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$MsiTransportPath kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($AgentBasePath)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($AgentBasePath, $false)
|
||||
return "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
}
|
||||
return "$MsiTransportPath removed."
|
||||
} catch {
|
||||
return "$MsiTransportPath left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
# What bootstrap MSI 1.3 installed under Program Files, on devices that still enroll with it.
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-LegacyMsiLoaderFolder {
|
||||
try {
|
||||
$programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
|
||||
if (-not $programFiles) { return $null }
|
||||
$company = Join-Path $programFiles 'AutopilotMonitor'
|
||||
$companyState = Get-FolderState $company
|
||||
if ($companyState -eq 'Missing') { return $null }
|
||||
if ($companyState -ne 'Ours') { return "$company left as it is ($companyState)." }
|
||||
$path = Join-Path $company 'Bootstrap'
|
||||
$state = Get-FolderState $path
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$path left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $path
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$path kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($company)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($company, $false)
|
||||
return "$path removed, and with it the empty $company."
|
||||
}
|
||||
return "$path removed."
|
||||
} catch {
|
||||
return "The loader folder of bootstrap MSI 1.3 is left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsLoaderRunFolder {
|
||||
param([string]$Folder)
|
||||
if (-not $Folder) { return $false }
|
||||
@@ -711,12 +766,17 @@ function Invoke-Bootstrap {
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
$agent = Get-AgentPresence
|
||||
if ($agent) {
|
||||
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
|
||||
$transport = Remove-MsiTransportFolder
|
||||
Write-Log "Agent present ($agent): $(if ($transport) { $transport } else { "$AgentBasePath is left as it is." })"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
if (-not $decision.Install) {
|
||||
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
@@ -729,6 +789,8 @@ function Invoke-Bootstrap {
|
||||
Write-LoaderLogs $loaderLogs
|
||||
Open-LogFile
|
||||
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
|
||||
$agentExePath = Join-Path $AgentBinPath $AgentExeName
|
||||
|
||||
@@ -467,11 +467,15 @@ Describe 'publish contract' {
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
# The file a SYSTEM custom action runs must not sit where a standard user could have
|
||||
# created the folder first: Program Files, never ProgramData.
|
||||
It 'is installed by the bootstrap MSI under Program Files' {
|
||||
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
|
||||
# the installer removes it. A standard user could have created that folder first, so the
|
||||
# action never runs the file as such: it checks the signature on the bytes it read and runs
|
||||
# those bytes, from System32.
|
||||
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
|
||||
$wxs | Should -Not -Match 'CommonAppDataFolder'
|
||||
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
|
||||
$wxs | Should -Not -Match 'ProgramFiles'
|
||||
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
|
||||
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -26,6 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1"));
|
||||
private static readonly string OobeScript = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1"));
|
||||
private static readonly string Wxs = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
|
||||
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -80,8 +84,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
|
||||
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
|
||||
|
||||
var wxs = File.ReadAllText(Path.Combine(RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, wxs);
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, Wxs);
|
||||
}
|
||||
|
||||
// The bootstrap MSI installs its loader copy where the installer removes it, starts
|
||||
// PowerShell in System32, and runs only bytes it has checked against the publisher pin
|
||||
// the loader applies to the installer. Windows Installer formats the command, so every
|
||||
// bracket and brace is escaped and the only property references are the two paths.
|
||||
[Fact]
|
||||
public void The_msi_puts_the_loader_copy_where_the_installer_removes_it_and_runs_only_what_it_checked()
|
||||
{
|
||||
XNamespace ns = "http://wixtoolset.org/schemas/v4/wxs";
|
||||
var package = XDocument.Parse(Wxs).Root.Element(ns + "Package");
|
||||
Assert.Equal("1fbe5515-d346-445c-b8b8-14ee79f97522", (string)package.Attribute("UpgradeCode"));
|
||||
|
||||
var root = package.Elements(ns + "StandardDirectory").Single();
|
||||
Assert.Equal("CommonAppDataFolder", (string)root.Attribute("Id"));
|
||||
var chain = root.Descendants(ns + "Directory").Select(d => (string)d.Attribute("Name")).ToArray();
|
||||
Assert.Equal("INSTALLFOLDER", (string)root.Descendants(ns + "Directory").Last().Attribute("Id"));
|
||||
var transport = Assignment(Installer, "MsiTransportPath").Replace("$AgentBasePath", Assignment(Installer, "AgentBasePath"));
|
||||
Assert.Equal("$env:ProgramData\\" + string.Join("\\", chain), transport);
|
||||
|
||||
var action = package.Elements(ns + "CustomAction").Single(a => (string)a.Attribute("Id") == "RunBootstrap");
|
||||
Assert.Equal("System64Folder", (string)action.Attribute("Directory"));
|
||||
Assert.Equal("deferred", (string)action.Attribute("Execute"));
|
||||
Assert.Equal("no", (string)action.Attribute("Impersonate"));
|
||||
Assert.Equal("ignore", (string)action.Attribute("Return"));
|
||||
|
||||
var exeCommand = (string)action.Attribute("ExeCommand");
|
||||
var command = exeCommand.Substring(exeCommand.IndexOf(" -Command ", StringComparison.Ordinal));
|
||||
Assert.Contains("-NonInteractive", exeCommand);
|
||||
Assert.Contains("[\\[]System.IO.File[\\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1')", command);
|
||||
Assert.Contains("Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'", command);
|
||||
Assert.Contains("$h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '" + Assignment(Loader, "ExpectedPublisher") + "'", command);
|
||||
// The hash is baked in by the build from the loader it embeds; a package built without it must not exist.
|
||||
Assert.Contains("<?ifndef var.LoaderSha256 ?>", Wxs);
|
||||
var workflow = File.ReadAllText(Path.Combine(RepoPaths.Root(), ".github", "workflows", "build-msi.yml"));
|
||||
Assert.Contains("wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256", workflow);
|
||||
Assert.Contains("Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256", workflow);
|
||||
Assert.Contains("[\\[]scriptblock[\\]]::Create([\\[]System.Text.Encoding[\\]]::UTF8.GetString($b))", command);
|
||||
Assert.DoesNotContain("-File", command);
|
||||
// Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
var unescaped = Regex.Replace(command, @"\[\\.\]", "");
|
||||
Assert.DoesNotMatch(@"[{}\[\]]", unescaped.Replace("[INSTALLFOLDER]", ""));
|
||||
Assert.Equal(new[] { "[INSTALLFOLDER]" }, Regex.Matches(command, @"\[[A-Za-z0-9_.]+\]").Cast<Match>().Select(m => m.Value).Distinct().ToArray());
|
||||
|
||||
var custom = package.Element(ns + "InstallExecuteSequence").Elements(ns + "Custom").Single();
|
||||
Assert.Equal("RunBootstrap", (string)custom.Attribute("Action"));
|
||||
Assert.Equal("NOT Installed", (string)custom.Attribute("Condition"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="ImeLogTracker"/> fed the shipped IME pattern pack (<c>rules/ime-log-patterns</c>,
|
||||
/// sorted as combine.js embeds it) and a CMTrace <c>IntuneManagementExtension.log</c> the test
|
||||
/// appends to, so a test drives the real patterns end to end.
|
||||
/// </summary>
|
||||
internal sealed class ShippedImePackHarness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public ShippedImePackHarness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public static List<ImeLogPattern> Pack()
|
||||
=> Directory.GetFiles(Path.Combine(RepoPaths.Root(), "rules", "ime-log-patterns"), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
/// <summary>A second tracker on the same log and state folder (an agent restart).</summary>
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: Pack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the appended entries are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages) => AppendAt(Now.AddSeconds(-60), messages);
|
||||
|
||||
/// <summary>Entries one second apart from <paramref name="firstUtc"/> (an older instant replays a previous enrollment).</summary>
|
||||
public void AppendAt(DateTime firstUtc, params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, firstUtc.AddSeconds(i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,5 +125,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
// No assertion needed beyond "did not throw"; the regression we guard against is
|
||||
// the original swallow-all behaviour, which the rotation refactor must preserve.
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AgentLogLevel.Info, "INFO")]
|
||||
[InlineData(AgentLogLevel.Debug, "INFO,DEBUG")]
|
||||
[InlineData(AgentLogLevel.Verbose, "INFO,DEBUG,VERBOSE")]
|
||||
[InlineData(AgentLogLevel.Trace, "INFO,DEBUG,VERBOSE,TRACE")]
|
||||
public void Each_level_adds_to_the_one_before_it(AgentLogLevel level, string expectedTags)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, level);
|
||||
|
||||
logger.Info("line");
|
||||
logger.Debug("line");
|
||||
logger.Verbose("line");
|
||||
logger.Trace("line");
|
||||
|
||||
var tags = ReadLines(tmp.Path).Select(Tag);
|
||||
Assert.Equal(expectedTags, string.Join(",", tags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_sends_an_identical_repeat_to_trace_and_a_change_back_to_its_level()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran again with new data", repeatKey: "rule:A");
|
||||
logger.Info("rule A ran", repeatKey: "rule:A");
|
||||
|
||||
Assert.Equal(new[] { "INFO", "TRACE", "INFO", "INFO" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_keeps_subjects_apart()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:System");
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:Application");
|
||||
logger.Debug("scan failed: access denied", repeatKey: "scan:System");
|
||||
|
||||
Assert.Equal(new[] { "DEBUG", "DEBUG", "TRACE" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatKey_does_not_remember_a_line_its_level_did_not_write()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
logger.Debug("waiting for the policy", repeatKey: "hello.poll");
|
||||
logger.SetLogLevel(AgentLogLevel.Debug);
|
||||
logger.Debug("waiting for the policy", repeatKey: "hello.poll");
|
||||
|
||||
Assert.Equal(new[] { "DEBUG" }, ReadLines(tmp.Path).Select(Tag).ToArray());
|
||||
}
|
||||
|
||||
private static string[] ReadLines(string dir) =>
|
||||
File.ReadAllLines(Path.Combine(dir, BaseName + ".log"));
|
||||
|
||||
// "[2026-10-30 12:00:00.000] [INFO] message" -> "INFO"
|
||||
private static string Tag(string line)
|
||||
{
|
||||
var open = line.IndexOf("] [", StringComparison.Ordinal) + 3;
|
||||
return line.Substring(open, line.IndexOf(']', open) - open);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -164,5 +164,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
var info = f.InfoEvent("hello_pin_status");
|
||||
Assert.Equal("false", info.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_HelloCompleted_handler_is_logged_and_the_completion_stands()
|
||||
{
|
||||
// The handler carries the Hello resolution to the decision engine; a fault there used
|
||||
// to vanish without a trace.
|
||||
using var f = new Fixture();
|
||||
f.Tracker.HelloCompleted += (_, _) => throw new InvalidOperationException("handler bug");
|
||||
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
|
||||
Assert.True(f.Tracker.IsHelloCompleted);
|
||||
f.InfoEvent("hello_provisioning_completed");
|
||||
Assert.Contains("[WARN] HelloTracker: HelloCompleted handler threw: handler bug", ReadLog(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_repeated_terminal_event_logs_COMPLETED_once()
|
||||
{
|
||||
// Live plus backfill deliver event 300 twice; only the first one completes Hello.
|
||||
using var f = new Fixture();
|
||||
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
f.Tracker.ProcessHelloEvent(300, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: true);
|
||||
|
||||
var log = ReadLog(f);
|
||||
Assert.Single(log.Split('\n'), l => l.Contains("Windows Hello provisioning COMPLETED successfully"));
|
||||
}
|
||||
|
||||
private static string ReadLog(Fixture f) =>
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(f.Tmp.Path, "agent.log"));
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -270,6 +270,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
new[] { "id=" + Guid1, "to=InProgress" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-ERROR-REPORT",
|
||||
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"Success\",\"NewValue\":\"Error\"},\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":-2016345060}}",
|
||||
new[] { "id=" + Guid1, "from=Success", "to=Error", "errorCode=-2016345060" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-POST-INSTALL-DETECTION",
|
||||
"[Win32App][DetectionActionHandler] Policy with id: " + Guid1 + " is expected to have enforcement state: Detected.",
|
||||
new[] { "id=" + Guid1 }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-POST-REBOOT-DETECTION",
|
||||
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"InProgressPendingReboot\",\"NewValue\":\"Success\"}}",
|
||||
new[] { "id=" + Guid1 }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-TOKEN-FAILURE",
|
||||
"Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929",
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// IME's sequence per app, driven through the shipped pattern pack: the check before the
|
||||
/// install, the installer, "Execution state … Success", the check right after the install,
|
||||
/// and the final state (D-392).
|
||||
/// <list type="bullet">
|
||||
/// <item>When the check after the install does not find the app, IME flips Success → Error
|
||||
/// with EnforcementErrorCode -2016345060 (0x87D1041C) BEFORE it logs the check's
|
||||
/// result line; that code is IME's verdict and must reach the error transition.</item>
|
||||
/// <item>The check after the install is marked ("expected to have enforcement state:
|
||||
/// Detected"); its result is reported through OnPostInstallDetection, and
|
||||
/// detectionResult stays the check before the install.</item>
|
||||
/// <item>After an exit code that asked for a restart, that check decides nothing; the check
|
||||
/// after the restart (InProgressPendingReboot → Success) is the one reported.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerDetectionAfterInstallTests
|
||||
{
|
||||
private const string AppId = "5c95bf94-1cf4-4629-88d1-3f616e7a405c";
|
||||
private const string DependencyId = "9d2b8a71-4e0c-4f5a-a3c6-71b0e2d5c8f4";
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
private static string DetectionResult(string state, string appId = AppId)
|
||||
=> "[Win32App][DetectionActionHandler] Detection for policy with id: " + appId +
|
||||
" resulted in action status: Success and detection state: " + state + ".";
|
||||
|
||||
private static string[] CheckAfterInstallMarkers() => new[]
|
||||
{
|
||||
"[Win32App][DetectionActionHandler] Detection running for policy with id: " + AppId + ".",
|
||||
"[Win32App][DetectionActionHandler] The last enforcement action was on policy with id: " + AppId + ".",
|
||||
"[Win32App][DetectionActionHandler] Policy with id: " + AppId + " is expected to have enforcement state: Detected.",
|
||||
};
|
||||
|
||||
private static string ExecutionDelta(string from, string to, string? errorCode = null)
|
||||
=> "[Win32App][ReportingManager] Execution state for app with id: " + AppId + " has been updated. Report delta: " +
|
||||
"{\"EnforcementState\":{\"OldValue\":\"" + from + "\",\"NewValue\":\"" + to + "\"}" +
|
||||
(errorCode == null ? "" : ",\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":" + errorCode + "}") + "}";
|
||||
|
||||
private sealed class Run
|
||||
{
|
||||
public ShippedImePackHarness Harness { get; } = new ShippedImePackHarness();
|
||||
public AppPackageState Package { get; } = new AppPackageState(AppId, 0);
|
||||
public List<(AppInstallationState To, string? Code)> Transitions { get; } = new List<(AppInstallationState, string?)>();
|
||||
public List<(string Detection, bool AfterReboot)> ChecksAfterInstall { get; } = new List<(string, bool)>();
|
||||
|
||||
public Run()
|
||||
{
|
||||
Package.UpdateState(AppInstallationState.Installing);
|
||||
Harness.Tracker.PackageStates.Add(Package);
|
||||
Harness.Tracker.OnAppStateChanged = (p, _, to) => { if (p.Id == AppId) Transitions.Add((to, p.ErrorCode)); };
|
||||
Harness.Tracker.OnPostInstallDetection = (p, detection, afterReboot, _) => ChecksAfterInstall.Add((detection, afterReboot));
|
||||
}
|
||||
|
||||
public void Lines(params string[] lines) => Harness.Append(lines);
|
||||
}
|
||||
|
||||
private static string[] CleanInstallThen(params string[] tail)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
PhaseLine,
|
||||
DetectionResult("NotDetected"), // the check before the install
|
||||
ExecutionDelta("InProgress", "Success"), // IME holds this back until its check
|
||||
};
|
||||
lines.AddRange(CheckAfterInstallMarkers());
|
||||
lines.AddRange(tail);
|
||||
return lines.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_after_install_failing_carries_IMEs_code_on_the_error_transition()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(
|
||||
ExecutionDelta("Success", "Error", "-2016345060"),
|
||||
DetectionResult("NotDetected"))); // the check's own result line comes last
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { AppInstallationState.Installed, AppInstallationState.Error }, run.Transitions.Select(t => t.To));
|
||||
Assert.Equal("-2016345060", run.Transitions.Last().Code);
|
||||
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
|
||||
Assert.Equal("IME-ERROR-REPORT", run.Package.ErrorPatternId);
|
||||
Assert.Equal(new[] { ("NotDetected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("NotDetected", run.Package.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_after_a_clean_install_is_reported_and_detectionResult_stays_the_check_before()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("NotDetected", run.Package.DetectionResult);
|
||||
Assert.Equal(AppInstallationState.Installed, run.Package.InstallationState);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_different_version_found_after_the_install_is_reported_as_such()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(DetectionResult("DetectedDifferentVersion")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("DetectedDifferentVersion", false) }, run.ChecksAfterInstall);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_dependency_checked_in_the_same_run_is_no_check_after_its_install()
|
||||
{
|
||||
// IME checks every app of the dependency chain in the run after an install; only the
|
||||
// installed app carries the markers.
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
var dependency = new AppPackageState(DependencyId, 1);
|
||||
run.Harness.Tracker.PackageStates.Add(dependency);
|
||||
run.Lines(CleanInstallThen(
|
||||
DetectionResult("Detected", DependencyId),
|
||||
DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("Detected", dependency.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_check_before_a_requested_restart_is_not_reported_the_one_after_it_is()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
var beforeRestart = new List<string>
|
||||
{
|
||||
PhaseLine,
|
||||
CurrentAppLine,
|
||||
"[Win32App] lpExitCode 3010",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot",
|
||||
ExecutionDelta("InProgress", "InProgressPendingReboot"),
|
||||
};
|
||||
beforeRestart.AddRange(CheckAfterInstallMarkers());
|
||||
beforeRestart.Add(DetectionResult("NotDetected")); // provisional: IME sets no error
|
||||
run.Lines(beforeRestart.ToArray());
|
||||
|
||||
await run.Harness.Pass();
|
||||
Assert.Empty(run.ChecksAfterInstall);
|
||||
|
||||
run.Lines(
|
||||
ExecutionDelta("InProgressPendingReboot", "Success"), // after the restart
|
||||
DetectionResult("Detected"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
Assert.Equal(new[] { ("Detected", true) }, run.ChecksAfterInstall);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lines_from_a_previous_enrollment_mark_nothing()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Harness.AppendAt(run.Harness.Now.AddDays(-2), CleanInstallThen(DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Empty(run.ChecksAfterInstall);
|
||||
Assert.Null(run.Package.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_IME_error_reports_carry_their_code_too()
|
||||
{
|
||||
// A download that fails is the app's first move to Error through the same line.
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(PhaseLine, ExecutionDelta("InProgressDownloadCompleted", "Error", "-2145091577"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, Assert.Single(run.Transitions).To);
|
||||
Assert.Equal("-2145091577", run.Package.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_report_without_a_code_leaves_the_code_empty()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(PhaseLine, ExecutionDelta("InProgress", "Error"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
|
||||
Assert.True(string.IsNullOrEmpty(run.Package.ErrorCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-79
@@ -1,15 +1,10 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
@@ -70,82 +65,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
// ---------------------------------------------------------------- shipped pack, end to end
|
||||
|
||||
private static string FindRulesPatternDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException($"Could not locate rules/ime-log-patterns walking up from {AppContext.BaseDirectory}");
|
||||
}
|
||||
|
||||
/// <summary>The shipped pack, exactly as combine.js embeds it (sorted by file name).</summary>
|
||||
private static List<ImeLogPattern> ShippedPack()
|
||||
=> Directory.GetFiles(FindRulesPatternDir(), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: ShippedPack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the CMTrace entries below are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, Now.AddSeconds(-60 + i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
[Fact]
|
||||
public async Task Shipped_pattern_captures_the_class_for_the_current_app()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, // activates the currentPhase pack (IME-EXITCODE, IME-EXITCODE-CLASS)
|
||||
@@ -165,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[Fact]
|
||||
public async Task Retry_loop_that_ends_in_success_reports_the_last_class()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
@@ -188,7 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
// IME-EXITCODE-CLASS and IME-EXITCODE-MAPPED-FAILED both match the Failed line: the
|
||||
// class is captured and the existing error path stays intact.
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
h.Tracker.PackageStates.Add(pkg);
|
||||
@@ -207,7 +133,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[Fact]
|
||||
public async Task Default_branch_and_raw_numeric_types_leave_the_class_unset()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
@@ -228,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
// A SoftReboot/HardReboot app completes only after the reboot; the restarted agent
|
||||
// must still know the class when the post-reboot Success line arrives.
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// IME rolls a log with File.Move at 3 MB. The tracker used to take the archive for a new file and
|
||||
/// read it from 0: every app line played again, finished apps fell back to Installing and the
|
||||
/// subgraph line of an older app marked the running app as skipped (session 190fed00).
|
||||
/// </summary>
|
||||
public sealed partial class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private const string FirstStamp = "20260910-193000";
|
||||
private const string SecondStamp = "20260910-193500";
|
||||
|
||||
private static string Folder(Harness h) => Path.GetDirectoryName(h.LogPath)!;
|
||||
|
||||
private static string ArchivePath(Harness h, string stamp = FirstStamp)
|
||||
=> Path.Combine(Folder(h), $"{Path.GetFileNameWithoutExtension(h.LogName)}-{stamp}.log");
|
||||
|
||||
private static string Roll(Harness h, string stamp = FirstStamp)
|
||||
{
|
||||
var archive = ArchivePath(h, stamp);
|
||||
File.Move(h.LogPath, archive);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private static List<ImeLogPattern> AppPatterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-POLICIES", Category = "always", Enabled = true,
|
||||
Pattern = @"^policies discovered = (?<policies>\[.*\])", Action = "policiesDiscovered",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-SUBGRAPH", Category = "always", Enabled = true,
|
||||
Pattern = @"^subgraph (?<id>[\w-]+)", Action = "cancelStuckAndSetCurrent",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-INSTALLING", Category = "always", Enabled = true,
|
||||
Pattern = @"^installing (?<id>[\w-]+)", Action = "updateStateInstalling",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
|
||||
Pattern = @"^installed (?<id>[\w-]+)", Action = "updateStateInstalled",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private const string AppPoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""},{""Id"":""app-2"",""Name"":""App Two""}]";
|
||||
|
||||
private static string OneFinishedOneRunning() =>
|
||||
Entry(AppPoliciesLine) + Entry("subgraph app-1") + Entry("installing app-1") + Entry("installed app-1")
|
||||
+ Entry("subgraph app-2") + Entry("installing app-2");
|
||||
|
||||
[Theory]
|
||||
[InlineData("AppWorkload.log")]
|
||||
[InlineData("AppActionProcessor.log")]
|
||||
[InlineData("HealthScripts.log")]
|
||||
[InlineData("AgentExecutor.log")]
|
||||
[InlineData("IntuneManagementExtension.log")]
|
||||
public async Task Rolled_over_log_is_not_read_again(string logName)
|
||||
{
|
||||
using var h = new Harness(logName, MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bytes_written_after_the_last_read_come_from_the_archive_once_and_first()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Line_held_back_at_the_end_of_the_file_is_finished_from_the_archive()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
var second = Entry("marker 2");
|
||||
h.Append(Entry("marker 1") + second.Substring(0, 10));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1 }, h.Matched());
|
||||
|
||||
h.Append(second.Substring(10));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 3"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_leaves_finished_and_running_apps_alone()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", AppPatterns());
|
||||
var transitions = new List<string>();
|
||||
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
|
||||
h.Append(OneFinishedOneRunning());
|
||||
await h.Pass();
|
||||
var before = transitions.ToList();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker unrelated"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(before, transitions);
|
||||
var states = h.Tracker.GetAllKnownPackageStates().ToDictionary(p => p.Id, p => p.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Installed, states["app-1"]);
|
||||
Assert.Equal(AppInstallationState.Installing, states["app-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_readable_identity_the_archive_is_read_again_as_before()
|
||||
{
|
||||
// The fallback, and the proof that the tests above catch the replay: this is what they saw before the fix.
|
||||
using var h = new Harness("AppWorkload.log", AppPatterns());
|
||||
h.Tracker.FileIdentityProvider = _ => null!;
|
||||
var transitions = new List<string>();
|
||||
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
|
||||
h.Append(OneFinishedOneRunning());
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("marker unrelated"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Contains("app-2:Installing->Skipped", transitions);
|
||||
Assert.Contains("app-1:Installed->Installing", transitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_file_that_sorts_before_its_archive_hands_over_its_bookmark()
|
||||
{
|
||||
// LogMaxHistory <= 1: the archive is _X.log, which sorts behind X.log. The pass reaches the
|
||||
// shrunken live file first, and the next rollover replaces the archive under the same name.
|
||||
using var h = new Harness("IntuneManagementExtension.log", MarkerPatterns());
|
||||
var archive = Path.Combine(Folder(h), "_IntuneManagementExtension.log");
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3") + Entry("marker 4"));
|
||||
await h.Pass();
|
||||
|
||||
File.Move(h.LogPath, archive);
|
||||
h.Append(Entry("marker 5"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, h.Matched());
|
||||
|
||||
h.Append(Entry("marker 6"));
|
||||
File.Delete(archive);
|
||||
File.Move(h.LogPath, archive);
|
||||
h.Append(Entry("marker 8"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 8 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_during_a_restart_with_a_longer_new_file_loses_and_repeats_nothing()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Restart();
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h);
|
||||
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6") + Entry("marker 7"));
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_rollovers_during_a_restart_with_the_bookmarked_file_deleted_lose_nothing()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
h.Restart();
|
||||
var first = Roll(h, FirstStamp);
|
||||
h.Append(Entry("marker 3"));
|
||||
Roll(h, SecondStamp);
|
||||
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6"));
|
||||
File.Delete(first);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archives_found_at_the_first_start_are_read_from_the_beginning_in_order()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
File.WriteAllText(ArchivePath(h, FirstStamp), Entry("marker 1") + Entry("marker 2"));
|
||||
File.WriteAllText(ArchivePath(h, SecondStamp), Entry("marker 3"));
|
||||
h.Append(Entry("marker 4"));
|
||||
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_script_in_flight_keeps_its_end_block_across_a_rollover()
|
||||
{
|
||||
// Reading the archive again used to restore the marker of the run in flight by accident.
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
|
||||
await h.Pass();
|
||||
|
||||
Roll(h);
|
||||
h.Append(Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed."));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Hello from E", script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_of_a_real_file_survives_a_rename_and_differs_from_the_next_file()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1"));
|
||||
var before = ImeLogFileIdentity.TryRead(h.LogPath);
|
||||
Assert.NotNull(before);
|
||||
|
||||
var archive = Roll(h);
|
||||
h.Append(Entry("marker 2"));
|
||||
|
||||
Assert.Equal(before, ImeLogFileIdentity.TryRead(archive));
|
||||
Assert.NotEqual(before, ImeLogFileIdentity.TryRead(h.LogPath));
|
||||
Assert.Null(ImeLogFileIdentity.TryRead(archive + ".missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// A bookmark belongs to a file, not to a name: IME rolls a log by renaming it, so the identity
|
||||
/// (volume plus file index) tells what happened to the bytes behind the bookmark.
|
||||
/// </summary>
|
||||
public sealed class LogFilePositionTrackerTests
|
||||
{
|
||||
private const string Live = @"C:\Logs\AppWorkload.log";
|
||||
private const string Archive = @"C:\Logs\AppWorkload-20260910-193000.log";
|
||||
private const string Underscore = @"C:\Logs\_AppWorkload.log";
|
||||
|
||||
private static readonly DateTime Checked = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Dictionary<string, string> Ids(params (string Path, string Id)[] entries)
|
||||
{
|
||||
var ids = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries) ids[entry.Path] = entry.Id;
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static LogFilePositionTracker TrackerWithBookmark(string path, string id, long position)
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.Reconcile(Ids((path, id)));
|
||||
tracker.MarkChecked(path, Checked);
|
||||
tracker.SetPosition(path, position);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renamed_file_keeps_its_bookmark_and_the_old_path_starts_over()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
var move = Assert.Single(moves);
|
||||
Assert.Equal(Live, move.From);
|
||||
Assert.Equal(Archive, move.To);
|
||||
Assert.Equal(3000, move.Position);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal("A", tracker.GetFileId(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
Assert.Equal("B", tracker.GetFileId(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_paths_keep_the_freshness_reference()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
Assert.True(tracker.HasSeen(Archive));
|
||||
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Archive));
|
||||
Assert.True(tracker.HasSeen(Live));
|
||||
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renamed_file_whose_old_path_is_not_recreated_yet_keeps_its_bookmark()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A")));
|
||||
|
||||
Assert.Single(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
Assert.False(tracker.HasIdentity(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_name_for_the_same_file_moves_nothing()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "A")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Live));
|
||||
Assert.False(tracker.HasSeen(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_file_stays_unseen_so_its_first_pass_is_not_taken_for_fresh_lines()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "X")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.True(tracker.HasIdentity(Archive));
|
||||
Assert.False(tracker.HasSeen(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_holding_another_file_with_no_one_to_take_over_starts_at_zero()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, "B")));
|
||||
|
||||
Assert.Empty(moves);
|
||||
// GetSafePosition alone would keep 3000 here when the new file is longer: lost lines.
|
||||
Assert.Equal(0, tracker.GetSafePosition(Live, 5000));
|
||||
Assert.Equal("B", tracker.GetFileId(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_identity_changes_nothing()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Live, null!), (Archive, null!)));
|
||||
|
||||
Assert.Empty(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Live));
|
||||
Assert.Equal("A", tracker.GetFileId(Live));
|
||||
Assert.True(tracker.HasIdentity(Archive));
|
||||
Assert.Null(tracker.GetFileId(Archive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_slot_archive_is_replaced_by_the_next_rollover()
|
||||
{
|
||||
// LogMaxHistory <= 1: IME deletes _X.log and renames X.log to it.
|
||||
var tracker = TrackerWithBookmark(Underscore, "A", 9000);
|
||||
tracker.Reconcile(Ids((Underscore, "A"), (Live, "B")));
|
||||
tracker.SetPosition(Live, 3000);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Underscore, "B"), (Live, "C")));
|
||||
|
||||
var move = Assert.Single(moves);
|
||||
Assert.Equal(Live, move.From);
|
||||
Assert.Equal(Underscore, move.To);
|
||||
Assert.Equal(3000, tracker.GetPosition(Underscore));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_renamed_file_can_leave_the_path_it_moved_into()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
tracker.Reconcile(Ids((Live, "A"), (Underscore, "B")));
|
||||
tracker.SetPosition(Underscore, 700);
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Underscore, "A"), (Archive, "B"), (Live, "C")));
|
||||
|
||||
Assert.Equal(2, moves.Count);
|
||||
Assert.Equal(3000, tracker.GetPosition(Underscore));
|
||||
Assert.Equal(700, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Behind_bookmark_only_when_smaller_than_the_position()
|
||||
{
|
||||
var tracker = TrackerWithBookmark(Live, "A", 3000);
|
||||
|
||||
Assert.True(tracker.IsBehindBookmark(Live, 2999));
|
||||
Assert.False(tracker.IsBehindBookmark(Live, 3000));
|
||||
Assert.False(tracker.IsBehindBookmark(Archive, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restored_identity_is_used_to_find_a_rollover_during_the_downtime()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(Live, 3000, 3000);
|
||||
tracker.RestoreFileId(Live, "A");
|
||||
|
||||
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
|
||||
|
||||
Assert.Single(moves);
|
||||
Assert.Equal(3000, tracker.GetPosition(Archive));
|
||||
Assert.Equal(0, tracker.GetPosition(Live));
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
|
||||
public sealed class ConfigMgrClientScoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nothing_found_scores_zero_and_is_not_detected()
|
||||
{
|
||||
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
|
||||
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_registered_running_client_scores_100()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(100, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
Assert.True(snapshot.Detected);
|
||||
Assert.Equal(10, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_service_counts_without_the_running_bonus()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(80, snapshot.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
|
||||
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
|
||||
return (sut, sink);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == "configmgr_client_detected").ToList();
|
||||
|
||||
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
|
||||
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
|
||||
|
||||
[Fact]
|
||||
public void No_client_never_emits()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
|
||||
Assert.Empty(Detected(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_detection_emits_the_v1_payload()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
|
||||
var evt = Assert.Single(Detected(sink));
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
|
||||
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
Assert.Equal(true, data["ccmRegistryFound"]);
|
||||
Assert.Equal(true, data["ccmDirectoryFound"]);
|
||||
Assert.Equal("Running", data["ccmServiceState"]);
|
||||
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
|
||||
Assert.Equal("ABC", data["siteCode"]);
|
||||
Assert.Equal(100, data["confidenceScore"]);
|
||||
Assert.Equal("startup", data["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_state_emits_once_a_changed_state_again()
|
||||
{
|
||||
var current = Client("Stopped", siteCode: null);
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
Assert.Single(Detected(sink));
|
||||
|
||||
current = Client("Running");
|
||||
sut.Check();
|
||||
|
||||
var events = Detected(sink);
|
||||
Assert.Equal(2, events.Count);
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
|
||||
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
|
||||
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
|
||||
Assert.Equal("Running", latest["ccmServiceState"]);
|
||||
Assert.Equal("poll", latest["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restart_into_the_same_state_stays_silent()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var current = Client("Running");
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
|
||||
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run1.Check();
|
||||
Assert.Single(Detected(sink1));
|
||||
|
||||
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run2.Check();
|
||||
Assert.Empty(Detected(sink2));
|
||||
|
||||
current = Client("Stopped");
|
||||
run2.Check();
|
||||
Assert.Single(Detected(sink2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emissions_are_capped_per_run()
|
||||
{
|
||||
var running = true;
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
|
||||
{
|
||||
sut.Check();
|
||||
running = !running;
|
||||
}
|
||||
|
||||
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-2
@@ -185,6 +185,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- phase gate for DevicePreparation providers
|
||||
|
||||
[Theory]
|
||||
[InlineData("device_preparation", true)]
|
||||
[InlineData("device_preparation_failed", true)] // a failed device preparation keeps its culprit
|
||||
[InlineData("none", true)] // unknown phase → keep observing
|
||||
[InlineData("unavailable", true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("device_preparation_done", false)]
|
||||
[InlineData("device_setup", false)]
|
||||
[InlineData("device_setup_failed", false)]
|
||||
[InlineData("account_setup", false)]
|
||||
[InlineData("esp_done", false)]
|
||||
public void Device_preparation_counts_until_it_is_done(string phase, bool expected)
|
||||
=> Assert.Equal(expected, EspPhaseSnapshot.DevicePreparationOpen(phase));
|
||||
|
||||
[Fact]
|
||||
public void DevicePreparation_provider_past_its_phase_drops_out_while_setupApps_still_counts()
|
||||
{
|
||||
// Field shape (client installed as a Win32 app): the client registers its
|
||||
// DevicePreparation provider after its own install with InstallationState=1, long
|
||||
// after device preparation ended. Nothing ever completes it.
|
||||
var snapshot = Snapshot(
|
||||
DevicePrep("ConfigMgr", installState: 1),
|
||||
SetupApps("Contoso", tracking: 0));
|
||||
var seen = new Dictionary<string, DateTime>
|
||||
{
|
||||
["devicePreparation|device|ConfigMgr"] = T0,
|
||||
["setupApps|device|Contoso"] = T0,
|
||||
};
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(30), Dwell,
|
||||
devicePreparationOpen: false);
|
||||
|
||||
Assert.False(eval.UpdatedFirstSeenUtc.ContainsKey("devicePreparation|device|ConfigMgr")); // reset
|
||||
var stalled = Assert.Single(eval.NewlyStalled);
|
||||
Assert.Equal("Contoso", stalled.Provider.Name);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- arm 2: sidecar-missing transition
|
||||
|
||||
[Fact]
|
||||
@@ -265,17 +305,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
// The phase defaults to "none" so the tests never read the test machine's own ESP state.
|
||||
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
|
||||
BuildDetector(StartupEventGate? gate = null)
|
||||
BuildDetector(StartupEventGate? gate = null, Func<string>? phase = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate,
|
||||
espPhaseReader: phase ?? (() => EspPhaseSnapshot.None));
|
||||
return (sut, sink, clock);
|
||||
}
|
||||
|
||||
private static readonly TimeSpan DwellSpan = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
|
||||
|
||||
private static PolicyProviderState SidecarDone() =>
|
||||
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null);
|
||||
|
||||
private static PolicyProviderState ConfigMgrDevicePrep(int? installationState) =>
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, installationState);
|
||||
|
||||
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
|
||||
@@ -435,6 +485,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_registered_after_device_preparation_never_emits()
|
||||
{
|
||||
// The ConfigMgr client as a Win32 app: its DevicePreparation provider shows up during
|
||||
// device setup with InstallationState=1 and stays there.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => "device_setup");
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
sut.Tick();
|
||||
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_real_device_preparation_hang_still_emits()
|
||||
{
|
||||
// Field shape: only ConfigMgr providers registered, the ESP never left device
|
||||
// preparation and failed with 0x800705b4.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
|
||||
ConfigMgrDevicePrep(1),
|
||||
});
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
|
||||
Assert.Contains(stalled, s => (string)s["kind"] == EspPolicyProviderProbe.KindDevicePreparation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_preparation_stall_message_names_the_provider_timeout()
|
||||
{
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var message = Assert.Single(StalledEvents(sink)).Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("until their timeout", message);
|
||||
Assert.DoesNotContain("without any timeout", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_skips_device_preparation_providers_past_their_phase()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var phase = EspPhaseSnapshot.DevicePreparation;
|
||||
var devicePrepOnly = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
var withContoso = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
SidecarDone(),
|
||||
ConfigMgrDevicePrep(1),
|
||||
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
|
||||
});
|
||||
var current = devicePrepOnly;
|
||||
|
||||
// Run 1: the DevicePreparation provider stalls during device preparation; later, in
|
||||
// device setup, a Setup/Apps provider stalls as well.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => current))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => phase);
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
phase = "device_setup";
|
||||
current = withContoso;
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Equal(2, StalledEvents(sink).Count);
|
||||
}
|
||||
|
||||
// Run 2 (restart in device setup): the same Contoso stall lands on the same
|
||||
// fingerprint only if the stale DevicePreparation provider stayed out of it.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => withContoso))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => "device_setup");
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_or_absent_tracking_never_emits()
|
||||
{
|
||||
|
||||
+59
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -304,6 +305,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Same(step.NewState, sut.CurrentState);
|
||||
}
|
||||
|
||||
// ========================================================================= Step log levels
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_a_stage_change_at_info()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
var sut = rig.Build();
|
||||
var (step, signal) = rig.ReduceAbort(); // SessionStarted -> Failed
|
||||
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.Contains(StepLines(rig), l => l.Contains("[INFO] DecisionStep:") && l.Contains("stage=SessionStarted->Failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_informational_pass_through_and_no_op_at_trace()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
var sut = rig.Build();
|
||||
|
||||
var (noOp, noOpSignal) = rig.ReduceSessionStarted(ordinal: 0);
|
||||
sut.ApplyStep(noOp, noOpSignal);
|
||||
var (info, infoSignal) = rig.ReduceInformational(sut, ordinal: 1);
|
||||
sut.ApplyStep(info, infoSignal);
|
||||
|
||||
var lines = StepLines(rig);
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.All(lines, l => Assert.Contains("[TRACE] DecisionStep:", l));
|
||||
Assert.Contains("(no-op)", lines[0]);
|
||||
Assert.Contains("kind=InformationalEvent", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyStep_logs_informational_pass_through_with_failed_effect_at_debug()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Logger.SetLogLevel(AgentLogLevel.Trace);
|
||||
rig.Effects.ScriptResult(new EffectRunResult(
|
||||
sessionMustAbort: false,
|
||||
abortReason: null,
|
||||
failures: new[] { new EffectFailure(DecisionEffectKind.EmitEventTimelineEntry, "emit failed", isTransient: false, exhaustedRetries: true) },
|
||||
classifierInvocations: 0,
|
||||
classifierSkippedByAntiLoop: 0));
|
||||
var sut = rig.Build();
|
||||
var (step, signal) = rig.ReduceInformational(sut, ordinal: 0);
|
||||
|
||||
sut.ApplyStep(step, signal);
|
||||
|
||||
Assert.Contains(StepLines(rig), l => l.Contains("[DEBUG] DecisionStep:") && l.Contains("failures=1"));
|
||||
}
|
||||
|
||||
private static string[] StepLines(Rig rig) =>
|
||||
System.IO.File.ReadAllLines(System.IO.Path.Combine(rig.Tmp.Path, "agent.log"))
|
||||
.Where(l => l.Contains("DecisionStep:"))
|
||||
.ToArray();
|
||||
|
||||
// ========================================================================= Argument validation
|
||||
|
||||
[Fact]
|
||||
|
||||
+22
-1
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
|
||||
/// virtual clock, no network metrics, no telemetry spool.
|
||||
/// </summary>
|
||||
private CollectorSurfaces CreateSurfaces()
|
||||
private CollectorSurfaces CreateSurfaces(AnalyzerConfiguration? analyzers = null)
|
||||
{
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
Analyzers = analyzers ?? new AnalyzerConfiguration(),
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
@@ -142,6 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 1)]
|
||||
public void ConfigMgr_client_host_exists_only_when_the_tenant_enables_the_watcher(bool enabled, int expected)
|
||||
{
|
||||
// Opt-in (D-382): with the watcher off the check must not run at all, not merely stay quiet.
|
||||
var hosts = CreateSurfaces(new AnalyzerConfiguration { EnableConfigMgrWatcher = enabled }).Hosts;
|
||||
try
|
||||
{
|
||||
Assert.Equal(expected, hosts.Count(h => h is ConfigMgrClientHost));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>The watchers only listen on the real keys; settings are played in, none is changed.</summary>
|
||||
public sealed class ProxySettingsHostTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _ingress = new FakeSignalIngressSink();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 53, 43, DateTimeKind.Utc));
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ProxyObserver _observer;
|
||||
private ProxySettings _settings = new ProxySettings(true, null, null, null, null);
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
public ProxySettingsHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_observer = new ProxyObserver(() => _settings, () => _route, _logger, _clock, TimeSpan.FromSeconds(30));
|
||||
_observer.CaptureBaseline();
|
||||
}
|
||||
|
||||
private ProxySettingsHost NewHost() => new ProxySettingsHost(_observer, _ingress, _clock, _logger);
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = new ProxySettings(false, null, null, null, null);
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> ProxyEvents() =>
|
||||
_ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& eventType == Constants.EventTypes.ProxyConfiguration)
|
||||
.ToList();
|
||||
|
||||
private string Log()
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
text += reader.ReadToEnd();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void A_change_goes_on_the_timeline_as_a_warning_with_what_moved()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
var signal = Assert.Single(ProxyEvents());
|
||||
Assert.Equal(nameof(EventSeverity.Warning), signal.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("Proxy settings changed: IE settings of this account: auto-detect -> no proxy", signal.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
Assert.Equal("changed", data["trigger"]);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", data["change"]);
|
||||
Assert.Equal("direct", data["route"]);
|
||||
Assert.Equal("Direct", data["proxyType"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_between_the_baseline_and_the_start_is_noticed_at_the_start()
|
||||
{
|
||||
SwitchToNoProxy();
|
||||
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
Assert.Single(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_change_means_no_event()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_reach_the_upload_path()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
_ingress.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("uploads are failing", Log());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_uploads_fail_says_so_once()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var log = Log();
|
||||
var first = log.IndexOf("stopping with uploads still failing", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
Assert.Equal(-1, log.IndexOf("stopping with uploads still failing", first + 1, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_after_the_stop_no_longer_posts()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
host.Stop();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_never_started_stays_silent_on_stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.DoesNotContain("stopping with uploads still failing", Log());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="TimelineEventStream.Publish"/> isolates subscribers: a throwing one must neither
|
||||
/// break the emit path nor starve the subscribers after it, and its fault must reach the log
|
||||
/// (Debug once, an identical repeat on Trace).
|
||||
/// </summary>
|
||||
public sealed class TimelineEventStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_throwing_subscriber_is_logged_and_the_next_one_still_runs()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
var sut = new TimelineEventStream(logger);
|
||||
var delivered = 0;
|
||||
sut.EventEmitted += ThrowingSubscriber;
|
||||
sut.EventEmitted += (_, _, _) => delivered++;
|
||||
|
||||
sut.Publish("app_install_started", EnrollmentPhase.Unknown, "ImeLogTracker");
|
||||
sut.Publish("app_install_completed", EnrollmentPhase.Unknown, "ImeLogTracker");
|
||||
|
||||
Assert.Equal(2, delivered);
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "agent.log"))
|
||||
.Where(l => l.Contains("TimelineEventStream: subscriber"))
|
||||
.ToArray();
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Contains("[DEBUG]", lines[0]);
|
||||
Assert.Contains("ThrowingSubscriber threw: subscriber bug", lines[0]);
|
||||
Assert.Contains("[TRACE]", lines[1]);
|
||||
}
|
||||
|
||||
private static void ThrowingSubscriber(string eventType, EnrollmentPhase phase, string source) =>
|
||||
throw new InvalidOperationException("subscriber bug");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
// xUnit1031 (no blocking Task.Wait in tests) intentionally suppressed — the API
|
||||
@@ -22,9 +24,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
/// Drives <see cref="TenantIdAwaiter.WaitForTenantIdCore"/> through every interesting
|
||||
/// path with a fake <see cref="IRegistryChangeSignal"/> + scripted probe so the
|
||||
/// orchestration logic is verified without touching the registry. The public
|
||||
/// <see cref="TenantIdAwaiter.WaitForTenantId"/> wrapper isn't covered here — the
|
||||
/// HKCU integration test in <c>RegistryWatcherIntegrationTests</c> proves the
|
||||
/// CompositeRegistryChangeSignal / RegNotifyChangeKeyValue plumbing separately.
|
||||
/// <c>WaitForTenantId</c> wrapper is covered through its internal overload with a scripted
|
||||
/// resolver (which probes log); the HKCU integration test in
|
||||
/// <c>RegistryWatcherIntegrationTests</c> proves the CompositeRegistryChangeSignal /
|
||||
/// RegNotifyChangeKeyValue plumbing separately.
|
||||
/// <para>
|
||||
/// Single-threaded collection: these tests use <c>Thread.Sleep</c> as a synchronisation
|
||||
/// primitive (waiting for the awaiter to enter its WaitAny loop before triggering the
|
||||
@@ -258,5 +261,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_miss_never_probes_with_the_logger()
|
||||
{
|
||||
// AgentBootstrap probes with its logger before it calls the awaiter; the resolver's
|
||||
// miss block must not land a second time from the awaiter's own first check.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
int loggedProbes = 0, silentProbes = 0;
|
||||
|
||||
var result = TenantIdAwaiter.WaitForTenantId(
|
||||
timeoutSeconds: 1,
|
||||
logger: logger,
|
||||
ct: CancellationToken.None,
|
||||
resolve: l => { if (l != null) loggedProbes++; else silentProbes++; return null!; },
|
||||
signalFactory: () => new FakeSignal());
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Equal(0, loggedProbes);
|
||||
Assert.True(silentProbes >= 1, $"expected a silent first check, saw {silentProbes}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_hit_probes_once_more_with_the_logger_for_the_source_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var loggedProbes = 0;
|
||||
|
||||
var result = TenantIdAwaiter.WaitForTenantId(
|
||||
timeoutSeconds: 600,
|
||||
logger: logger,
|
||||
ct: CancellationToken.None,
|
||||
resolve: l => { if (l != null) loggedProbes++; return "tenant-1"; },
|
||||
signalFactory: () => throw new InvalidOperationException("no wait expected on a hit"));
|
||||
|
||||
Assert.Equal("tenant-1", result);
|
||||
Assert.Equal(1, loggedProbes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -258,13 +258,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var f = new Fixture();
|
||||
using var adapter = new AadJoinWatcherAdapter(
|
||||
f.Watcher, f.Ingress, f.Clock,
|
||||
onRealUserJoined: () => throw new InvalidOperationException("boom"));
|
||||
onRealUserJoined: () => throw new InvalidOperationException("boom"),
|
||||
logger: f.Logger);
|
||||
|
||||
// The signal post completes BEFORE the callback fires — even if the callback
|
||||
// throws, the decision signal must already be on the bus.
|
||||
adapter.TriggerFromTest("alice@contoso.com", "x");
|
||||
|
||||
Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AadUserJoinedLate);
|
||||
// ...and the fault is in the log instead of vanishing.
|
||||
Assert.Contains(
|
||||
"[WARN] AadJoinWatcherAdapter: onRealUserJoined callback threw: boom",
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(f.Tmp.Path, "agent.log")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -92,6 +92,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_post_install_detection_is_suppressed_fresh_emits()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-check", 0);
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
|
||||
+44
@@ -663,6 +663,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("00:01:45.159", info.Payload["doDownloadDuration"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostInstallDetection_emits_the_check_after_the_install_as_its_own_info_event()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = AppPackageState.Restore(
|
||||
"0e3be4ce-790b-4081-831c-d72d92f3cc9b", 0, "Contoso VPN",
|
||||
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
|
||||
new HashSet<string>(),
|
||||
AppInstallationState.Installed, true, 100, 0, 0,
|
||||
exitCode: "0", exitCodeClass: "Success");
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, f.Clock.UtcNow.AddSeconds(-5));
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
|
||||
Assert.Equal(EventSeverity.Info.ToString(), info.Payload[SignalPayloadKeys.Severity]);
|
||||
Assert.Equal(app.Id, info.Payload["appId"]);
|
||||
Assert.Equal("Contoso VPN", info.Payload["appName"]);
|
||||
Assert.Equal("Detected", info.Payload["postInstallDetection"]);
|
||||
Assert.Equal("false", info.Payload["afterReboot"]);
|
||||
Assert.Equal("Success", info.Payload["exitCodeClass"]);
|
||||
// detectionResult is the check before the install; this event never carries it.
|
||||
Assert.False(info.Payload.ContainsKey("detectionResult"));
|
||||
// Informational only: no app state signal.
|
||||
Assert.Empty(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostInstallDetection_not_detected_is_a_warning_and_the_check_after_a_restart_says_so()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("8a1c2f6e-5b7d-4e93-9c4a-0f2d6b8e1a37", 0);
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "NotDetected", afterReboot: true, f.Clock.UtcNow.AddSeconds(-5));
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal(EventSeverity.Warning.ToString(), info.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("NotDetected", info.Payload["postInstallDetection"]);
|
||||
Assert.Equal("true", info.Payload["afterReboot"]);
|
||||
Assert.Contains("after the restart", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_null_app_or_empty_id_is_ignored()
|
||||
{
|
||||
|
||||
+41
@@ -126,6 +126,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
Assert.Equal(2, collector.CollectInvocationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_then_Dispose_logs_Stopped_once()
|
||||
{
|
||||
// Hosts call Stop and then Dispose, which stops again; the log used to show
|
||||
// "[X] Stopped" twice in the same millisecond.
|
||||
using var tmp = new TempDirectory();
|
||||
var collector = new BlockingCollector(NewPost(), NewLogger(tmp));
|
||||
|
||||
collector.Start();
|
||||
collector.Stop();
|
||||
collector.Dispose();
|
||||
|
||||
var log = File.ReadAllText(Path.Combine(tmp.Path, "logs", "agent.log"));
|
||||
Assert.Equal(1, CountOccurrences(log, "[BlockingCollector] Stopped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_without_Start_logs_no_Stopped_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var collector = new BlockingCollector(NewPost(), logger);
|
||||
logger.Info("marker"); // make sure the log file exists
|
||||
|
||||
collector.Dispose();
|
||||
|
||||
var log = File.ReadAllText(Path.Combine(tmp.Path, "logs", "agent.log"));
|
||||
Assert.DoesNotContain("[BlockingCollector] Stopped", log);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string value)
|
||||
{
|
||||
int count = 0, index = 0;
|
||||
while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only collector subclass: signals when <see cref="Collect"/> is entered and
|
||||
/// blocks until the test releases it, so we can exercise the reentrancy gate
|
||||
|
||||
+162
-9
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -14,8 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="BackendTelemetryUploader"/> emits upload-cadence log lines (flushing / ingest
|
||||
/// OK / ingest TRANSIENT / ingest PERMANENT) so the transport path is observable. The
|
||||
/// success-path lines (flushing, ingest OK) are pipeline mechanics and live on Debug;
|
||||
/// OK / ingest TRANSIENT / ingest PERMANENT) so the transport path is observable. The send
|
||||
/// ("flushing") is mechanics and lives on Trace, the success record ("ingest OK") on Verbose;
|
||||
/// failures (TRANSIENT/PERMANENT/TIMEOUT/NETWORK) live on Warning/Error and remain
|
||||
/// visible at the default Info level. We assert on log-file content because AgentLogger
|
||||
/// writes to disk; checking the file is the most faithful production proxy.
|
||||
@@ -35,6 +36,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
requiresImmediateFlush: false,
|
||||
enqueuedAtUtc: At);
|
||||
|
||||
private static ProxyObserver NewProxyObserver(AgentLogger logger) =>
|
||||
new ProxyObserver(
|
||||
() => new ProxySettings(false, null, null, null, null),
|
||||
() => "direct",
|
||||
logger,
|
||||
new VirtualClock(At),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
private static string ReadLogs(string path)
|
||||
{
|
||||
// AgentLogger writes to a dated file; scan the whole dir to stay robust to format.
|
||||
@@ -54,11 +63,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatch_logs_flush_start_and_ingest_OK_on_success_at_debug()
|
||||
public async Task UploadBatch_logs_flush_start_at_trace_and_ingest_OK_at_verbose()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
// Success-path transport lines live on Debug; capture at that level to assert them.
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Debug);
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Trace);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
@@ -75,11 +83,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
|
||||
// AgentLogger writes each line with File.AppendAllText — content is already on disk.
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("BackendTelemetryUploader: flushing 2 item(s)", logContent);
|
||||
// Each upload now carries a per-request correlation id (X-Correlation-ID) for
|
||||
// end-to-end tracing; it precedes the item count in the cadence log lines.
|
||||
Assert.Contains("[TRACE] BackendTelemetryUploader: flushing 2 item(s)", logContent);
|
||||
// Each upload carries a per-request correlation id (X-Correlation-ID) for end-to-end
|
||||
// tracing; the success record carries the batch's item range so it stands alone.
|
||||
Assert.Contains("[VERBOSE] BackendTelemetryUploader: ingest OK (corr=", logContent);
|
||||
Assert.Contains("items=2, firstId=1, lastId=2, bytes=", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatch_success_at_verbose_logs_the_record_without_the_send_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Verbose);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http,
|
||||
baseUrl: "https://backend.test",
|
||||
tenantId: "T1",
|
||||
logger: logger);
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.DoesNotContain("BackendTelemetryUploader: flushing", logContent);
|
||||
Assert.Contains("BackendTelemetryUploader: ingest OK (corr=", logContent);
|
||||
Assert.Contains("items=2", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,5 +244,126 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.DoesNotContain("flushing", logContent);
|
||||
Assert.DoesNotContain("ingest", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_failure_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest NETWORK fail after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new TaskCanceledException("A task was canceled."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest TIMEOUT after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interception_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.IsIntercepted);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains(", route=direct).", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_upload_ends_the_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.Contains("ProxyObserver: uploads work again after 1 failed attempt(s)", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answer_from_the_backend_that_is_not_a_success_leaves_the_series_alone()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.InternalServerError);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain("ProxyObserver: uploads", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_observer_the_failure_line_stays_as_it_was()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger);
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("items=1): ", logContent);
|
||||
Assert.DoesNotContain("route=", logContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -550,6 +550,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Contains("network", result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_error_reason_names_the_cause_instead_of_the_generic_text()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.Handler.QueueThrow(new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException(
|
||||
"Unable to connect to the remote server",
|
||||
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.TimedOut),
|
||||
WebExceptionStatus.ConnectFailure,
|
||||
null)));
|
||||
|
||||
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.StartsWith("network: status=ConnectFailure, socket=TimedOut: ", result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpClient_timeout_maps_to_Transient()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
|
||||
public sealed class ProxyObserverTests : IDisposable
|
||||
{
|
||||
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
|
||||
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
|
||||
private ProxySettings _settings = AutoDetect;
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
|
||||
new ProxyObserver(
|
||||
readSettings ?? (() => _settings),
|
||||
readRoute ?? (() => _route),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
_clock,
|
||||
routeBound ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
private ProxyObserver StartedObserver()
|
||||
{
|
||||
var observer = NewObserver();
|
||||
observer.CaptureBaseline();
|
||||
return observer;
|
||||
}
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = NoProxy;
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<string> LogLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void The_baseline_names_settings_and_route_at_info()
|
||||
{
|
||||
StartedObserver();
|
||||
|
||||
var line = Assert.Single(LogLines());
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
{
|
||||
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
|
||||
}
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_warns_once_with_both_sides()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = Assert.Single(LogLines().Skip(1));
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
|
||||
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewriting_the_same_content_says_nothing()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, null);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_says_uploads_do_not_use_it()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
|
||||
Assert.Contains("do not use WinHTTP", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
observer.CheckForChange();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_reaches_the_subscriber_with_settings_and_route()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
ProxySettings? seen = null;
|
||||
string? seenWhat = null;
|
||||
string? seenRoute = null;
|
||||
observer.SettingsChanged += (settings, what, route) =>
|
||||
{
|
||||
seen = settings;
|
||||
seenWhat = what;
|
||||
seenRoute = route;
|
||||
};
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Equal(NoProxy, seen);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
|
||||
Assert.Equal("direct", seenRoute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_reach_the_caller()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_failure_ties_the_series_to_the_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_clock.Advance(TimeSpan.FromMinutes(23));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromSeconds(90));
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromSeconds(62));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.FromSeconds(21));
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_notices_a_change_the_watcher_did_not_report()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var lines = LogLines();
|
||||
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
|
||||
Assert.Contains("after the proxy settings changed", lines.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_series_writes_its_finding_once()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_without_a_change_says_the_settings_are_unchanged()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
|
||||
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_before_the_last_success_is_not_blamed()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_clock.Advance(TimeSpan.FromMinutes(9));
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_reports_settings_that_came_back()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_settings = AutoDetect;
|
||||
_route = "proxy.example.test:8080";
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_failing_sums_it_up()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
|
||||
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendSucceeded();
|
||||
var before = LogLines().Count;
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
Assert.Equal(before, LogLines().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
|
||||
{
|
||||
var throwing = false;
|
||||
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
|
||||
observer.CaptureBaseline();
|
||||
throwing = true;
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_is_not_a_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = ProxySettings.Unreadable(5);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_route_read_that_throws_reads_as_unknown()
|
||||
{
|
||||
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
|
||||
{
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var reads = 0;
|
||||
var observer = NewObserver(
|
||||
readRoute: () =>
|
||||
{
|
||||
Interlocked.Increment(ref reads);
|
||||
release.Wait();
|
||||
return "direct";
|
||||
},
|
||||
routeBound: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal(1, Volatile.Read(ref reads));
|
||||
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class ProxySettingsTests
|
||||
{
|
||||
private static ProxySettings Settings(
|
||||
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
|
||||
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
|
||||
|
||||
[Fact]
|
||||
public void Each_kind_of_setting_describes_itself()
|
||||
{
|
||||
Assert.Equal("no proxy", Settings().Describe());
|
||||
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
|
||||
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
|
||||
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
|
||||
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
|
||||
Assert.Equal("direct", Settings().DescribeWinHttp());
|
||||
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
|
||||
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null, null, "Direct")]
|
||||
[InlineData(true, null, null, "AutoDetect")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
|
||||
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_has_its_own_type()
|
||||
{
|
||||
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equal_content_is_equal_and_any_field_makes_it_different()
|
||||
{
|
||||
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
|
||||
Assert.NotEqual(Settings(autoDetect: true), Settings());
|
||||
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
|
||||
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_string_counts_as_not_set()
|
||||
{
|
||||
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user:secret@p:8080")]
|
||||
[InlineData("http://user:secret@p:8080")]
|
||||
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
|
||||
[InlineData("user:se=cret@p:8080")]
|
||||
[InlineData("user:se;cret@p:8080")]
|
||||
[InlineData("http=user:se;cret@p:8080")]
|
||||
[InlineData("user:se://cret@p:8080")]
|
||||
public void Credentials_never_reach_logs_or_events(string configured)
|
||||
{
|
||||
var settings = Settings(proxy: configured, winHttp: configured);
|
||||
var data = settings.ToEventData();
|
||||
|
||||
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
|
||||
{
|
||||
Assert.DoesNotContain("user", text);
|
||||
Assert.DoesNotContain("cret", text);
|
||||
}
|
||||
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
|
||||
Assert.True((bool)data["proxyEnabled"]);
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("p:8080")]
|
||||
[InlineData("http://p:8080")]
|
||||
[InlineData("http=p:8080;https=q:8443")]
|
||||
public void A_value_without_credentials_stays_as_it_is(string configured)
|
||||
{
|
||||
Assert.Equal(configured, Settings(proxy: configured).Proxy);
|
||||
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
|
||||
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
|
||||
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
|
||||
[InlineData("not a url with secret", "(unparsable URL)")]
|
||||
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(pac: configured).PacUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_names_what_moved()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
|
||||
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
|
||||
Settings(winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: proxy bypass list changed",
|
||||
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_account_settings_count_as_a_change_for_uploads()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.True(Settings().DiffersForUploadsFrom(before));
|
||||
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_tells_auto_detect_from_direct()
|
||||
{
|
||||
var data = Settings(autoDetect: true).ToEventData();
|
||||
|
||||
Assert.Equal("AutoDetect", data["proxyType"]);
|
||||
Assert.True((bool)data["autoDetect"]);
|
||||
Assert.False((bool)data["proxyEnabled"]);
|
||||
Assert.Null(data["proxyServer"]);
|
||||
Assert.False((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Null(data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_carries_a_real_winhttp_proxy()
|
||||
{
|
||||
var data = Settings(winHttp: "p:8080").ToEventData();
|
||||
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Equal("p:8080", data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_real_settings_of_this_account_can_be_read()
|
||||
{
|
||||
var settings = ProxySettingsReader.Read();
|
||||
|
||||
Assert.False(settings.IsUnreadable, settings.Describe());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_route_can_be_read()
|
||||
{
|
||||
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(await read));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -33,10 +34,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader();
|
||||
public TelemetryUploadOrchestrator Sut { get; }
|
||||
|
||||
public Rig()
|
||||
public Rig(AgentLogger? logger = null)
|
||||
{
|
||||
Spool = new TelemetrySpool(Tmp.Path, Clock);
|
||||
Sut = new TelemetryUploadOrchestrator(Spool, Uploader, Clock, batchSize: 10, retryBackoffs: new[] { TimeSpan.Zero });
|
||||
Sut = new TelemetryUploadOrchestrator(Spool, Uploader, Clock, batchSize: 10, retryBackoffs: new[] { TimeSpan.Zero }, logger: logger);
|
||||
}
|
||||
|
||||
public void Dispose() { Sut.Dispose(); Tmp.Dispose(); }
|
||||
@@ -140,7 +141,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
[Fact]
|
||||
public async Task Handler_exception_does_not_break_drain_cursor()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var logTmp = new TempDirectory();
|
||||
var logger = new AgentLogger(logTmp.Path, AgentLogLevel.Info);
|
||||
using var rig = new Rig(logger);
|
||||
rig.Sut.Enqueue(Draft());
|
||||
rig.Uploader.QueueOkWithSignals(deviceKillSignal: true);
|
||||
|
||||
@@ -150,6 +153,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
// Upload still succeeded — the cursor moves, drain returns success.
|
||||
Assert.Equal(1, drain.UploadedItems);
|
||||
Assert.Equal(0, drain.FailedBatches);
|
||||
// The handler fault is no longer silent.
|
||||
Assert.Contains(
|
||||
"[WARN] TelemetryUploadOrchestrator: ServerResponseReceived handler threw: handler bug",
|
||||
System.IO.File.ReadAllText(System.IO.Path.Combine(logTmp.Path, "agent.log")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,9 @@ using System;
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
@@ -69,5 +71,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
|
||||
}
|
||||
|
||||
// The .NET Framework shape of a failed connect or name lookup through HttpClient.
|
||||
private static Exception SendFailureChain(WebExceptionStatus status, SocketError socketError) =>
|
||||
new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException("Unable to connect to the remote server", new SocketException((int)socketError), status, null));
|
||||
|
||||
[Theory]
|
||||
[InlineData(WebExceptionStatus.ConnectFailure, SocketError.TimedOut, "status=ConnectFailure, socket=TimedOut: ")]
|
||||
[InlineData(WebExceptionStatus.NameResolutionFailure, SocketError.HostNotFound, "status=NameResolutionFailure, socket=HostNotFound: ")]
|
||||
public void Send_failure_names_status_and_socket_error(WebExceptionStatus status, SocketError socketError, string expectedPrefix)
|
||||
{
|
||||
var described = TransportErrorCodes.DescribeSendFailure(SendFailureChain(status, socketError));
|
||||
|
||||
Assert.StartsWith(expectedPrefix, described);
|
||||
Assert.DoesNotContain("An error occurred while sending the request", described);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Send_failure_of_a_tls_handshake_names_status_and_native_code()
|
||||
{
|
||||
var described = TransportErrorCodes.DescribeSendFailure(TlsFailureChain(new Win32Exception(IllegalMessage)));
|
||||
|
||||
Assert.StartsWith("status=SecureChannelFailure, win32=0x80090326: ", described);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Send_failure_without_details_is_the_innermost_message()
|
||||
{
|
||||
Assert.Equal("inner", TransportErrorCodes.DescribeSendFailure(new HttpRequestException("outer", new InvalidOperationException("inner"))));
|
||||
Assert.Equal("boom", TransportErrorCodes.DescribeSendFailure(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.DescribeSendFailure(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_refused_connect_names_the_endpoint_it_tried()
|
||||
{
|
||||
// The endpoint in the message is what tells the backend from a proxy, and only a real
|
||||
// connect produces it: a hand-built SocketException carries none.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
|
||||
using var client = new HttpClient(new HttpClientHandler { UseProxy = false });
|
||||
var failure = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://127.0.0.1:{port}/"));
|
||||
|
||||
var described = TransportErrorCodes.DescribeSendFailure(failure);
|
||||
Assert.StartsWith("status=ConnectFailure, socket=ConnectionRefused: ", described);
|
||||
Assert.Contains($"127.0.0.1:{port}", described);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level: Info (default), Debug, Verbose.
|
||||
/// Log verbosity level: Info (default), Debug, Verbose, Trace (see <see cref="AgentLogLevel"/>).
|
||||
/// Overridable via remote config.
|
||||
/// </summary>
|
||||
public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
|
||||
|
||||
@@ -151,9 +151,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
try
|
||||
{
|
||||
if (attempt == 1)
|
||||
_logger.Info("Fetching remote configuration from backend...");
|
||||
_logger.Debug("Fetching remote configuration from backend...");
|
||||
else
|
||||
_logger.Info($"Fetching remote configuration from backend (attempt {attempt}/{maxAttempts})...");
|
||||
_logger.Debug($"Fetching remote configuration from backend (attempt {attempt}/{maxAttempts})...");
|
||||
|
||||
var config = await CallBackendAsync();
|
||||
|
||||
@@ -208,7 +208,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
if (attempt < maxAttempts)
|
||||
{
|
||||
var delay = InitialFetchRetryBackoff[attempt - 1];
|
||||
_logger.Info($"Retrying remote config fetch in {delay.TotalSeconds:0}s...");
|
||||
_logger.Debug($"Retrying remote config fetch in {delay.TotalSeconds:0}s...");
|
||||
try { await DelayBetweenAttemptsAsync(delay); }
|
||||
catch (TaskCanceledException) { break; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Log verbosity levels for the agent.
|
||||
/// Info = normal operational messages (default)
|
||||
/// Debug = includes DEBUG-tagged lines (component state, config, decisions)
|
||||
/// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing)
|
||||
/// Log verbosity levels for the agent. Each level adds to the one before it.
|
||||
/// Info = the enrollment flow an admin follows: one line per thing that happened
|
||||
/// (state change, milestone, result), repeated only when its content changed.
|
||||
/// Debug = why and how: decision details, extra attributes, suppression decisions,
|
||||
/// attempts in retry loops. Grows with the number of apps, scripts and phases,
|
||||
/// not with elapsed time.
|
||||
/// Verbose = everything known about one thing: full payloads, raw JSON, adapter mappings,
|
||||
/// the record of each upload batch. Every line carries content of its own.
|
||||
/// Trace = the mechanics: watcher arm/fire, poll ticks without a result, debounce,
|
||||
/// per-item enqueue, per-signal pass-through. Grows with time or with the number
|
||||
/// of notifications; one line alone says almost nothing.
|
||||
/// An identical repeat of a line goes to Trace after its first occurrence (repeatKey).
|
||||
/// A caught unexpected exception is never logged below Debug. Warning and Error are always
|
||||
/// written.
|
||||
/// </summary>
|
||||
public enum AgentLogLevel
|
||||
{
|
||||
@@ -32,6 +43,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
private AgentLogLevel _logLevel;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
// Last message written under each repeatKey; bounded because callers key by call site
|
||||
// plus subject (rule, category, file, policy).
|
||||
private readonly ConcurrentDictionary<string, string> _lastMessageByRepeatKey =
|
||||
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
private readonly long _maxFileSizeBytes;
|
||||
private readonly string _logDirectory;
|
||||
private readonly string _logFileBaseName; // "agent"
|
||||
@@ -122,21 +138,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
Log("TRACE", message);
|
||||
}
|
||||
|
||||
public void Verbose(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Verbose(string message, string repeatKey = null)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Verbose)
|
||||
Log("VERBOSE", message);
|
||||
if (_logLevel < AgentLogLevel.Verbose) return;
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("VERBOSE", message);
|
||||
}
|
||||
|
||||
public void Debug(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Debug(string message, string repeatKey = null)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Debug)
|
||||
Log("DEBUG", message);
|
||||
if (_logLevel < AgentLogLevel.Debug) return;
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("DEBUG", message);
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
/// <param name="repeatKey">Names the call site and its subject. A message equal to the
|
||||
/// last one written under this key goes to Trace instead.</param>
|
||||
public void Info(string message, string repeatKey = null)
|
||||
{
|
||||
Log("INFO", message);
|
||||
if (IsRepeat(repeatKey, message)) Trace(message);
|
||||
else Log("INFO", message);
|
||||
}
|
||||
|
||||
// Records the message under the key and reports whether it equals the previous one.
|
||||
// Only reached once the requested level is enabled, so a line that was never written
|
||||
// cannot turn its first visible occurrence into a repeat.
|
||||
private bool IsRepeat(string repeatKey, string message)
|
||||
{
|
||||
if (repeatKey == null) return false;
|
||||
var repeat = false;
|
||||
_lastMessageByRepeatKey.AddOrUpdate(
|
||||
repeatKey,
|
||||
message,
|
||||
(_, previous) =>
|
||||
{
|
||||
repeat = string.Equals(previous, message, StringComparison.Ordinal);
|
||||
return message;
|
||||
});
|
||||
return repeat;
|
||||
}
|
||||
|
||||
public void Warning(string message)
|
||||
|
||||
+5
-1
@@ -114,7 +114,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public string AppType { get; private set; }
|
||||
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
|
||||
public int AttemptNumber { get; private set; }
|
||||
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
|
||||
/// <summary>
|
||||
/// IME's detection check outside the check right after an install: Detected, NotDetected or
|
||||
/// DetectedDifferentVersion — for an app being installed, the check before the install. The
|
||||
/// check after the install is reported through OnPostInstallDetection instead (D-392).
|
||||
/// </summary>
|
||||
public string DetectionResult { get; private set; }
|
||||
|
||||
// Delivery Optimization telemetry (populated from [DO TEL] log entries OR Get-DeliveryOptimizationStatus poll)
|
||||
|
||||
+1
-1
@@ -426,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
if (_logger == null) return;
|
||||
var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}"));
|
||||
_logger.Debug($"App states [{Count}]: {stateList}");
|
||||
_logger.Verbose($"App states [{Count}]: {stateList}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Which file a path holds right now: volume serial plus file index. IME rolls a log with
|
||||
/// <c>File.Move</c>, so the identity stays with the bytes when the name changes.
|
||||
/// </summary>
|
||||
internal static class ImeLogFileIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Null when the file cannot be opened. An attribute-only handle with full sharing, so it
|
||||
/// can never make IME's rename of the log fail.
|
||||
/// </summary>
|
||||
public static string TryRead(string path)
|
||||
{
|
||||
using (var handle = FileNativeMethods.CreateFileW(
|
||||
path,
|
||||
FileNativeMethods.FILE_READ_ATTRIBUTES,
|
||||
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE | FileNativeMethods.FILE_SHARE_DELETE,
|
||||
IntPtr.Zero,
|
||||
FileNativeMethods.OPEN_EXISTING,
|
||||
0,
|
||||
IntPtr.Zero))
|
||||
{
|
||||
if (handle.IsInvalid) return null;
|
||||
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info)) return null;
|
||||
return $"{info.VolumeSerialNumber:X8}-{info.FileIndexHigh:X8}{info.FileIndexLow:X8}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-26
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
// PR3-A2: dedup state for `IME impersonation` lines. ~24 identical per session at
|
||||
// DEBUG flooded the log; we now emit on user change + a 60s rollup with the count.
|
||||
// DEBUG flooded the log; a user change logs at Debug, the 60s rollup with the count at Trace.
|
||||
private string _lastImpersonationUser;
|
||||
private int _impersonationRepeatCount;
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
@@ -111,12 +111,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _impersonationLastRollupUtc >= ImpersonationRollupInterval)
|
||||
{
|
||||
_logger.Debug($"IME impersonation: same as before (user='{current}', n={_impersonationRepeatCount} since last rollup)");
|
||||
_logger.Trace($"IME impersonation: same as before (user='{current}', n={_impersonationRepeatCount} since last rollup)");
|
||||
_impersonationLastRollupUtc = now;
|
||||
_impersonationRepeatCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Info for what a line of this enrollment reports. A line replayed from a previous
|
||||
/// enrollment's log (historic-replay guard) is context, not progress: Debug. Only for
|
||||
/// handlers that run while that line is processed, since the flag describes the line.
|
||||
/// </summary>
|
||||
private void LogLineActivity(string message, string repeatKey = null)
|
||||
{
|
||||
if (_currentLineIsHistoricReplay) _logger.Debug(message + " (historic line)", repeatKey);
|
||||
else _logger.Info(message, repeatKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of IME's check right after an install (D-392). After an exit code that asked for a
|
||||
/// restart that check decides nothing: IME sets no error then and checks again after the
|
||||
/// restart (IME-POST-REBOOT-DETECTION marks that check), so it is not reported.
|
||||
/// </summary>
|
||||
private void HandlePostInstallDetection(string appId, string detection, bool afterReboot)
|
||||
{
|
||||
var pkg = _packageStates.GetPackage(appId);
|
||||
if (pkg == null) return;
|
||||
if (!afterReboot
|
||||
&& (string.Equals(pkg.ExitCodeClass, "SoftReboot", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(pkg.ExitCodeClass, "HardReboot", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: check after the install of {pkg.Name ?? appId} precedes the requested restart ({detection}), IME decides after it");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: check after the install of {pkg.Name ?? appId}: {detection}{(afterReboot ? " (after the restart)" : string.Empty)}");
|
||||
try { OnPostInstallDetection?.Invoke(pkg, detection, afterReboot, LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger.Warning($"OnPostInstallDetection handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void HandleDoTelemetry(string json)
|
||||
{
|
||||
try
|
||||
@@ -254,7 +287,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// map). Prefer the source CMTrace timestamp; latest start wins on a policy re-run.
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
_healthScriptStartTimes[id] = (startedAtUtc, CaptureLastMatchedProvenance());
|
||||
_logger.Info($"ImeLogTracker: health script started: {id}");
|
||||
LogLineActivity($"ImeLogTracker: health script started: {id}", repeatKey: "ImeLogTracker.hsStarted:" + id);
|
||||
|
||||
// A replayed line of a previous enrollment bypasses the recurrence gate: the
|
||||
// adapter drops its event, and a dropped run must not count as the policy's
|
||||
@@ -263,7 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
&& !_recurringScripts.OnStart(id, startedAtUtc, policyType, LastMatchedPatternId))
|
||||
{
|
||||
_stateDirty = true;
|
||||
_logger.Debug($"ImeLogTracker: health script {id} is a recurring policy — start held until its result is known");
|
||||
_logger.Debug($"ImeLogTracker: health script {id} is a recurring policy — start held until its result is known", repeatKey: "ImeLogTracker.hsStartHeld:" + id);
|
||||
return;
|
||||
}
|
||||
_stateDirty = true;
|
||||
@@ -479,7 +512,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
RecordInvocationMarker(null, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
if (!string.IsNullOrEmpty(kind) && !string.Equals(kind, "powershell", StringComparison.OrdinalIgnoreCase))
|
||||
_logger.Debug($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
_logger.Trace($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
}
|
||||
|
||||
private void HandleAgentExecutorCompleted()
|
||||
@@ -1054,10 +1087,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
DurationBasis = "script_runtime",
|
||||
};
|
||||
|
||||
_logger.Info($"ImeLogTracker: health-script {scriptPart} early-signal for {id}: " +
|
||||
$"compliance={compliance}, exit={exitCode}, " +
|
||||
$"stdout={(stdout == null ? "n/a" : stdout.Length + " chars")}, " +
|
||||
$"stderr={(stderr == null ? "n/a" : stderr.Length + " chars")}");
|
||||
LogLineActivity($"ImeLogTracker: health-script {scriptPart} early-signal for {id}: " +
|
||||
$"compliance={compliance}, exit={exitCode}, " +
|
||||
$"stdout={(stdout == null ? "n/a" : stdout.Length + " chars")}, " +
|
||||
$"stderr={(stderr == null ? "n/a" : stderr.Length + " chars")}",
|
||||
repeatKey: "ImeLogTracker.hsEarly:" + scriptPart + ":" + id);
|
||||
|
||||
EmitScriptEvent(enriched);
|
||||
|
||||
@@ -1281,11 +1315,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
EmitScriptEvent(postDetection);
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: health-script result emitted for {policyId} " +
|
||||
$"(status={remediationStatus?.ToString() ?? "?"}, " +
|
||||
$"firstDetectExit={firstDetectExit?.ToString() ?? "n/a"}, " +
|
||||
$"remediationExit={remediationExit?.ToString() ?? "n/a"}, " +
|
||||
$"lastDetectExit={lastDetectExit?.ToString() ?? "n/a"})");
|
||||
// "result for", not "emitted": the recurrence gate in EmitScriptEvent may hold a
|
||||
// repeat back and logs that itself.
|
||||
LogLineActivity($"ImeLogTracker: health-script result for {policyId} " +
|
||||
$"(status={remediationStatus?.ToString() ?? "?"}, " +
|
||||
$"firstDetectExit={firstDetectExit?.ToString() ?? "n/a"}, " +
|
||||
$"remediationExit={remediationExit?.ToString() ?? "n/a"}, " +
|
||||
$"lastDetectExit={lastDetectExit?.ToString() ?? "n/a"})",
|
||||
repeatKey: "ImeLogTracker.hsResult:" + policyId);
|
||||
}
|
||||
|
||||
private static ScriptExecutionState BuildHealthScriptPhase(
|
||||
@@ -1355,7 +1392,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_stateDirty = true;
|
||||
if (!emit)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: health script {script.PolicyId} {script.ScriptPart} repeats its last reported result — counted, not emitted");
|
||||
_logger.Debug($"ImeLogTracker: health script {script.PolicyId} {script.ScriptPart} repeats its last reported result — counted, not emitted", repeatKey: "ImeLogTracker.hsRepeat:" + script.PolicyId + ":" + script.ScriptPart);
|
||||
return;
|
||||
}
|
||||
if (releasedStart != null) RaiseScriptStarted(releasedStart);
|
||||
@@ -1411,7 +1448,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private void HandleImeStarted()
|
||||
{
|
||||
_logger.Info("ImeLogTracker: IME Agent Started detected");
|
||||
LogLineActivity("ImeLogTracker: IME Agent Started detected");
|
||||
|
||||
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
|
||||
// We intentionally do NOT mark it as Installed here because IME may retry the app.
|
||||
@@ -1487,11 +1524,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_logger.Debug(
|
||||
"ImeLogTracker: zero-app user session observed before the user ESP phase " +
|
||||
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup");
|
||||
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup", repeatKey: "ImeLogTracker.zeroAppDeferred");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed");
|
||||
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed", repeatKey: "ImeLogTracker.zeroAppUserSession");
|
||||
OnUserSessionCompleted?.Invoke();
|
||||
}
|
||||
|
||||
@@ -1509,7 +1546,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// which would corrupt tracking if we allowed it to trigger a phase change.
|
||||
if (phaseOrd < _currentPhaseOrder)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
|
||||
_logger.Trace($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1550,7 +1587,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_phasePackageSnapshots[_lastEspPhaseDetected] =
|
||||
new List<AppPackageState>(_packageStates);
|
||||
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
|
||||
_logger.Debug($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
|
||||
}
|
||||
|
||||
_packageStates.Clear();
|
||||
@@ -1564,12 +1601,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// First detection of any phase OR a real transition into a new phase: surface on Info
|
||||
// so phase boundaries are visible without enabling Debug. Re-matches of the same phase
|
||||
// (IME re-emits the phase string periodically as it re-evaluates app sets) stay on
|
||||
// Debug to avoid Info repetition.
|
||||
// (IME re-emits the phase string periodically as it re-evaluates app sets) are
|
||||
// identical repeats and go to Trace (D-394).
|
||||
if (isFirstOrChangedPhase)
|
||||
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
else
|
||||
_logger.Debug($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
_logger.Trace($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
ActivatePatterns(logPhaseIsCurrentPhase);
|
||||
|
||||
OnEspPhaseChanged?.Invoke(espPhaseString);
|
||||
@@ -1623,7 +1660,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
// Ignore user-targeted packages during device setup (we only track device phase in general)
|
||||
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
|
||||
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
|
||||
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages", repeatKey: "ImeLogTracker.policies");
|
||||
OnPoliciesDiscovered?.Invoke(policiesJson);
|
||||
}
|
||||
}
|
||||
@@ -1672,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_allAppsCompletedFired = true;
|
||||
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
|
||||
OnAllAppsCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1687,7 +1723,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
|
||||
_logger.Verbose($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
|
||||
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-22
@@ -51,23 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
if (!Directory.Exists(_logFolder))
|
||||
return;
|
||||
|
||||
// Get all matching log files, sorted by name (archived files come before current).
|
||||
// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
|
||||
// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
|
||||
// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
|
||||
// the Win32 "*.ext" 8.3 search-pattern quirk.
|
||||
var files = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var files = EnumerateLogFiles();
|
||||
if (FileIdentitiesAreDue(files)) ReconcileFileIdentities(files);
|
||||
|
||||
// Σ(file length − bookmark) after this pass — the tracker's queue depth.
|
||||
long backlogBytes = 0;
|
||||
@@ -83,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
catch (FileNotFoundException) { }
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}");
|
||||
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}", repeatKey: "ImeLogTracker.ioError:" + filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +79,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All matching log files, sorted by name (archived files come before current).
|
||||
/// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
|
||||
/// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
|
||||
/// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
|
||||
/// the Win32 "*.ext" 8.3 search-pattern quirk.
|
||||
/// </summary>
|
||||
private List<string> EnumerateLogFiles()
|
||||
{
|
||||
var files = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On the first pass (a restart can have rolled logs over) and when a file has no known
|
||||
/// identity yet, which is how a rolled-over archive first shows up. Nothing is opened in
|
||||
/// the regular poll.
|
||||
/// </summary>
|
||||
private bool FileIdentitiesAreDue(List<string> files)
|
||||
{
|
||||
if (!_fileIdentitiesReconciled) return true;
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
if (!_positionTracker.HasIdentity(filePath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets every bookmark follow its file (see <see cref="LogFilePositionTracker.Reconcile"/>).
|
||||
/// Reading a rolled-over log again would replay every app and script line in it: finished
|
||||
/// apps fall back to Installing, and the subgraph line of an older app marks the app that is
|
||||
/// running right now as skipped.
|
||||
/// </summary>
|
||||
private void ReconcileFileIdentities(List<string> files)
|
||||
{
|
||||
_fileIdentitiesReconciled = true;
|
||||
|
||||
var idByPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var filePath in files) idByPath[filePath] = FileIdentityProvider(filePath);
|
||||
|
||||
foreach (var move in _positionTracker.Reconcile(idByPath))
|
||||
{
|
||||
MoveFileContext(move.From, move.To);
|
||||
_logger.Info($"ImeLogTracker: {Path.GetFileName(move.From)} rolled over to {Path.GetFileName(move.To)} - continuing at offset {move.Position} instead of reading it again");
|
||||
}
|
||||
_stateDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
|
||||
/// by the overwrite check that may move the bookmark back first (see
|
||||
@@ -104,6 +149,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) return 0;
|
||||
|
||||
// IME renamed the file after this pass listed the folder, or its archive sorts behind it
|
||||
// (_IntuneManagementExtension.log). Either way the archive needs the bookmark before the
|
||||
// read below resets it.
|
||||
if (_positionTracker.IsBehindBookmark(filePath, fileInfo.Length))
|
||||
ReconcileFileIdentities(EnumerateLogFiles());
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
@@ -516,7 +567,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"updatestateskipped", "updatestateerror", "updatestatepostponed",
|
||||
"captureexitcode", "capturehresult", "captureexitcodeclass", "captureappversion",
|
||||
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"capturedetectionresult", "markpostinstalldetection", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
"cancelstuckandsetcurrent", "updatedotelemetry",
|
||||
// Not app-mutating, but a stale token-failure replayed from a previous
|
||||
@@ -588,7 +639,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_currentLineIsHistoricReplay = isStaleReplayLine;
|
||||
if (isStaleReplayLine && AppMutatingActions.Contains(pattern.Action?.ToLower() ?? string.Empty))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({ResolveEntryUtc(entry):o})");
|
||||
_logger.Trace($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({ResolveEntryUtc(entry):o})");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -684,7 +735,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
break;
|
||||
|
||||
case "enrollmentcompleted":
|
||||
_logger.Info("ImeLogTracker: User session completed detected");
|
||||
LogLineActivity("ImeLogTracker: User session completed detected", repeatKey: "ImeLogTracker.userSessionCompleted");
|
||||
OnUserSessionCompleted?.Invoke();
|
||||
break;
|
||||
|
||||
@@ -790,10 +841,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
break;
|
||||
|
||||
case "markpostinstalldetection":
|
||||
// The check right after an install starts (D-392); its result line follows.
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_postInstallCheckPending[id] = pattern.Parameters.ContainsKey("afterReboot")
|
||||
&& pattern.Parameters["afterReboot"] == "true";
|
||||
break;
|
||||
|
||||
case "capturedetectionresult":
|
||||
var detectionVal = match.Groups["detection"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal))
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(detectionVal))
|
||||
break;
|
||||
bool afterReboot;
|
||||
if (_postInstallCheckPending.TryGetValue(id, out afterReboot))
|
||||
{
|
||||
// detectionResult stays the check before the install.
|
||||
_postInstallCheckPending.Remove(id);
|
||||
HandlePostInstallDetection(id, detectionVal, afterReboot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal);
|
||||
}
|
||||
break;
|
||||
|
||||
case "updatestatepostponed":
|
||||
|
||||
+29
@@ -616,6 +616,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
|
||||
|
||||
/// <summary>
|
||||
/// A log was rolled over: what the tracker knows about its bytes goes with them. The markers keep
|
||||
/// <see cref="_lastPlatformMarker"/> alive, so the run in flight still owns its lines in the fresh file;
|
||||
/// the ledger stays behind and empties, an archive is not checked for overwrites.
|
||||
/// </summary>
|
||||
private void MoveFileContext(string fromPath, string toPath)
|
||||
{
|
||||
var from = Path.GetFileName(fromPath);
|
||||
var to = Path.GetFileName(toPath);
|
||||
|
||||
List<InvocationMarker> markers;
|
||||
if (_invocationMarkers.TryGetValue(from, out markers))
|
||||
{
|
||||
_invocationMarkers.Remove(from);
|
||||
_invocationMarkers[to] = markers;
|
||||
}
|
||||
if (_platformMarkerFiles.Contains(from)) _platformMarkerFiles.Add(to);
|
||||
|
||||
FileLedger ledger;
|
||||
if (_ledgers.TryGetValue(from, out ledger)) ledger.Clear();
|
||||
|
||||
HeldTail held;
|
||||
if (_heldTails.TryGetValue(fromPath, out held))
|
||||
{
|
||||
_heldTails.Remove(fromPath);
|
||||
_heldTails[toPath] = held;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
|
||||
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
|
||||
|
||||
+30
-12
@@ -50,6 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates.
|
||||
private readonly HashSet<string> _seenAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// IME's check right after an install (D-392): app id → "after the restart the installer
|
||||
// requested". Set by IME-POST-INSTALL-DETECTION / IME-POST-REBOOT-DETECTION, consumed by
|
||||
// the app's next detection result line, which IME always logs last in that check.
|
||||
// Transient on purpose: a marker whose result line was never read must not survive an
|
||||
// agent restart and turn a later ordinary check into a post-install result.
|
||||
private readonly Dictionary<string, bool> _postInstallCheckPending = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup
|
||||
// re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase).
|
||||
private static readonly Dictionary<string, int> EspPhaseOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -81,6 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// system clock, ImeLogHost rewires it to the agent IClock, tests to their VirtualClock.
|
||||
internal Func<DateTime> UtcNowProvider { get; set; } = () => DateTime.UtcNow;
|
||||
|
||||
// Which file a path holds; null when unreadable. Tests swap it to cover that path.
|
||||
internal Func<string, string> FileIdentityProvider { get; set; } = ImeLogFileIdentity.TryRead;
|
||||
|
||||
// False until the bookmarks were checked against the disk once in this process.
|
||||
private bool _fileIdentitiesReconciled;
|
||||
|
||||
/// <summary>
|
||||
/// CMTrace timestamps from IME are emitted in UTC, but DateTimeKind may arrive as
|
||||
/// Unspecified depending on the parser path. Normalize via SpecifyKind=Utc rather than
|
||||
@@ -487,7 +500,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnImeStarted { get; set; }
|
||||
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
|
||||
public Action<string> OnPoliciesDiscovered { get; set; }
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
|
||||
@@ -513,6 +525,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IME's check right after an install found its result (D-392): the package, the detection
|
||||
/// state (Detected, NotDetected, DetectedDifferentVersion), whether the check followed the
|
||||
/// restart the installer requested, and the result line's timestamp. Not raised for the
|
||||
/// check before a requested restart: IME decides only after it.
|
||||
/// </summary>
|
||||
public Action<AppPackageState, string, bool, DateTime?> OnPostInstallDetection { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -939,13 +959,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
if (!Directory.Exists(_logFolder)) return;
|
||||
var inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
var files = new List<string>();
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var files = EnumerateLogFiles();
|
||||
// First: the read below resets the bookmark of a file that shrank, and the archive
|
||||
// that file moved to during the downtime still needs that bookmark.
|
||||
ReconcileFileIdentities(files);
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
@@ -979,9 +996,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LoadState();
|
||||
|
||||
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
|
||||
// before the poll loop so every replayed line already finds its era; read-only on
|
||||
// the position tracker. Replay tooling anchors nothing — its logs did not come from
|
||||
// this device's bootstrap.
|
||||
// before the poll loop so every replayed line already finds its era. Replay tooling
|
||||
// anchors nothing — its logs did not come from this device's bootstrap.
|
||||
if (!SimulationMode) PreScanEras();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
@@ -1165,6 +1181,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
var fullPath = Path.Combine(_logFolder, kvp.Key);
|
||||
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
|
||||
_positionTracker.RestoreFileId(fullPath, kvp.Value.FileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,7 +1348,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
state.FilePositions[fileName] = new FilePositionData
|
||||
{
|
||||
Position = kvp.Value.Position,
|
||||
LastKnownSize = kvp.Value.LastKnownSize
|
||||
LastKnownSize = kvp.Value.LastKnownSize,
|
||||
FileId = _positionTracker.GetFileId(kvp.Key)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -250,5 +250,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
public long Position { get; set; }
|
||||
public long LastKnownSize { get; set; }
|
||||
|
||||
/// <summary>Which file the position belongs to (see <see cref="LogFilePositionTracker.Reconcile"/>); null on older state files.</summary>
|
||||
public string FileId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+127
-6
@@ -13,12 +13,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private readonly Dictionary<string, FilePositionState> _positions
|
||||
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the safe read position for a file, detecting rollover/truncation.
|
||||
/// If the current file size is smaller than the stored position, the file
|
||||
/// has been rotated - resets to 0 to read from beginning.
|
||||
/// Returns 0 if no position has been recorded yet.
|
||||
/// </summary>
|
||||
// Which file each path held when last checked (see Reconcile). Apart from _positions: a file
|
||||
// whose identity is known but which no pass looked at yet must still count as unseen for
|
||||
// HasSeen. A null value means the identity could not be read.
|
||||
private readonly Dictionary<string, string> _fileIds
|
||||
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this file was already observed in an earlier pass.
|
||||
/// <para>
|
||||
@@ -31,6 +31,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public bool HasSeen(string filePath) => _positions.ContainsKey(filePath);
|
||||
|
||||
/// <summary>Whether <see cref="Reconcile"/> already looked at this path.</summary>
|
||||
public bool HasIdentity(string filePath) => _fileIds.ContainsKey(filePath);
|
||||
|
||||
/// <summary>The identity recorded for this path, or null.</summary>
|
||||
public string GetFileId(string filePath)
|
||||
{
|
||||
string id;
|
||||
return _fileIds.TryGetValue(filePath, out id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Restores a persisted identity. <see cref="Reconcile"/> checks it against the disk on the next pass.</summary>
|
||||
public void RestoreFileId(string filePath, string fileId)
|
||||
{
|
||||
if (fileId != null) _fileIds[filePath] = fileId;
|
||||
}
|
||||
|
||||
/// <summary>True when the file is smaller than its bookmark: it was rolled over or truncated.</summary>
|
||||
public bool IsBehindBookmark(string filePath, long currentFileSize)
|
||||
{
|
||||
FilePositionState state;
|
||||
return _positions.TryGetValue(filePath, out state) && currentFileSize < state.Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the bookmarks in line with the files on disk. A bookmark belongs to a file, not to
|
||||
/// a name: IME rolls a log by renaming it, so a known file under a new path keeps its
|
||||
/// position and only the bytes behind it are still unread. The old path starts at 0 with
|
||||
/// whatever file it holds next, and keeps its freshness reference because that file was
|
||||
/// written since the last look. Returns the moves.
|
||||
/// <list type="bullet">
|
||||
/// <item>A file that is still at its old path as well (a hard link) moves nothing.</item>
|
||||
/// <item>A path that holds another file with no bookmark to take over starts at 0, even
|
||||
/// when the new file is longer than the old position.</item>
|
||||
/// <item>An unreadable identity (null) changes nothing, the path keeps its bookmark.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public List<BookmarkMove> Reconcile(IDictionary<string, string> idByPath)
|
||||
{
|
||||
var moves = new List<BookmarkMove>();
|
||||
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in idByPath)
|
||||
{
|
||||
if (entry.Value == null || GetFileId(entry.Key) == entry.Value) continue;
|
||||
var holder = FindBookmarkLeftBehind(entry.Value, entry.Key, idByPath);
|
||||
if (holder != null && claimed.Add(holder))
|
||||
moves.Add(new BookmarkMove(holder, entry.Key, _positions[holder].Position));
|
||||
}
|
||||
|
||||
// In two steps: a path can be source and target at once (X.log becomes _X.log while
|
||||
// _X.log's own file was replaced).
|
||||
var movedStates = new List<FilePositionState>();
|
||||
foreach (var move in moves)
|
||||
{
|
||||
var state = _positions[move.From];
|
||||
movedStates.Add(state);
|
||||
_positions[move.From] = new FilePositionState { LastCheckedUtc = state.LastCheckedUtc };
|
||||
_fileIds.Remove(move.From);
|
||||
}
|
||||
for (var i = 0; i < moves.Count; i++)
|
||||
{
|
||||
_positions[moves[i].To] = movedStates[i];
|
||||
_fileIds[moves[i].To] = idByPath[moves[i].To];
|
||||
}
|
||||
|
||||
foreach (var entry in idByPath)
|
||||
{
|
||||
if (entry.Value == null)
|
||||
{
|
||||
if (!_fileIds.ContainsKey(entry.Key)) _fileIds[entry.Key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
string known;
|
||||
FilePositionState state;
|
||||
if (_fileIds.TryGetValue(entry.Key, out known) && known != null && known != entry.Value
|
||||
&& _positions.TryGetValue(entry.Key, out state))
|
||||
{
|
||||
state.Position = 0;
|
||||
state.LastKnownSize = 0;
|
||||
}
|
||||
_fileIds[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
private string FindBookmarkLeftBehind(string fileId, string newPath, IDictionary<string, string> idByPath)
|
||||
{
|
||||
foreach (var known in _fileIds)
|
||||
{
|
||||
if (known.Value != fileId || string.Equals(known.Key, newPath, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!_positions.ContainsKey(known.Key)) continue;
|
||||
|
||||
string stillThere;
|
||||
if (idByPath.TryGetValue(known.Key, out stillThere) && stillThere == fileId) continue;
|
||||
return known.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the safe read position for a file, detecting rollover/truncation.
|
||||
/// If the current file size is smaller than the stored position, the file
|
||||
/// has been rotated - resets to 0 to read from beginning.
|
||||
/// Returns 0 if no position has been recorded yet.
|
||||
/// </summary>
|
||||
public long GetSafePosition(string filePath, long currentFileSize)
|
||||
{
|
||||
FilePositionState state;
|
||||
@@ -144,6 +250,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One bookmark that followed its file to a new path.</summary>
|
||||
public sealed class BookmarkMove
|
||||
{
|
||||
public BookmarkMove(string from, string to, long position)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
public string From { get; }
|
||||
public string To { get; }
|
||||
public long Position { get; }
|
||||
}
|
||||
|
||||
public class FilePositionState
|
||||
{
|
||||
public long Position { get; set; }
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
|
||||
/// with the V1 payload when the client is first seen, and again when what it reports changes
|
||||
/// (the service starts, a site code is assigned) — the client often installs after the agent
|
||||
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
|
||||
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
|
||||
/// </summary>
|
||||
internal sealed class ConfigMgrClientDetector
|
||||
{
|
||||
internal const string SourceLabel = "ConfigMgrClientDetector";
|
||||
|
||||
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
|
||||
internal const int MaxEmissionsPerRun = 10;
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
|
||||
private string? _lastFingerprint;
|
||||
private int _emissions;
|
||||
private bool _checkedOnce;
|
||||
|
||||
public ConfigMgrClientDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe round.</summary>
|
||||
internal void Check()
|
||||
{
|
||||
var trigger = _checkedOnce ? "poll" : "startup";
|
||||
_checkedOnce = true;
|
||||
|
||||
var snapshot = ConfigMgrClientProbe.Read(_logger);
|
||||
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
|
||||
return;
|
||||
|
||||
_lastFingerprint = snapshot.Fingerprint;
|
||||
if (_startupGate != null && !_startupGate.ShouldEmit(
|
||||
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
|
||||
{
|
||||
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
|
||||
return;
|
||||
}
|
||||
|
||||
_emissions++;
|
||||
var score = snapshot.ConfidenceScore;
|
||||
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
|
||||
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = _clock.UtcNow,
|
||||
EventType = Constants.EventTypes.ConfigMgrClientDetected,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = SourceLabel,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["ccmRegistryFound"] = snapshot.RegistryFound,
|
||||
["ccmDirectoryFound"] = snapshot.DirectoryFound,
|
||||
["ccmServiceState"] = snapshot.ServiceState,
|
||||
["ccmVersion"] = snapshot.Version ?? "unknown",
|
||||
["siteCode"] = snapshot.SiteCode ?? "unknown",
|
||||
["confidenceScore"] = score,
|
||||
["detectedAt"] = trigger,
|
||||
}
|
||||
});
|
||||
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
|
||||
|
||||
_logger?.Info(
|
||||
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
|
||||
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.ServiceProcess;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
|
||||
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
|
||||
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
|
||||
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
|
||||
/// exists, so devices without the client never throw on every poll.
|
||||
/// </summary>
|
||||
internal static class ConfigMgrClientProbe
|
||||
{
|
||||
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
|
||||
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
|
||||
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
|
||||
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
|
||||
internal const string CcmExecServiceName = "CcmExec";
|
||||
|
||||
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
|
||||
internal const string ServiceNotFound = "not_found";
|
||||
|
||||
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
|
||||
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
|
||||
|
||||
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe();
|
||||
|
||||
try
|
||||
{
|
||||
// The agent is AnyCPU; the client writes the 64-bit view.
|
||||
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
|
||||
bool registryFound;
|
||||
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
|
||||
registryFound = ccm != null;
|
||||
|
||||
string? version;
|
||||
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
|
||||
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
|
||||
|
||||
string? siteCode;
|
||||
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
|
||||
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
|
||||
|
||||
bool serviceInstalled;
|
||||
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
|
||||
serviceInstalled = service != null;
|
||||
|
||||
var directoryFound = Directory.Exists(
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
|
||||
|
||||
return new ConfigMgrClientSnapshot(
|
||||
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
|
||||
return ConfigMgrClientSnapshot.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadServiceState()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var controller = new ServiceController(CcmExecServiceName))
|
||||
return controller.Status.ToString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return ServiceNotFound; // removed between the key read and the query
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
|
||||
|
||||
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<ConfigMgrClientSnapshot>? _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
|
||||
internal sealed class ConfigMgrClientSnapshot
|
||||
{
|
||||
public static readonly ConfigMgrClientSnapshot Empty =
|
||||
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
|
||||
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
|
||||
{
|
||||
RegistryFound = registryFound;
|
||||
DirectoryFound = directoryFound;
|
||||
ServiceState = serviceState;
|
||||
Version = version;
|
||||
SiteCode = siteCode;
|
||||
}
|
||||
|
||||
public bool RegistryFound { get; }
|
||||
public bool DirectoryFound { get; }
|
||||
|
||||
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
|
||||
public string ServiceState { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
public string? SiteCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
|
||||
/// detection from 50 on.
|
||||
/// </summary>
|
||||
public int ConfidenceScore =>
|
||||
(DirectoryFound ? 10 : 0)
|
||||
+ (RegistryFound ? 20 : 0)
|
||||
+ (Version != null ? 15 : 0)
|
||||
+ (SiteCode != null ? 15 : 0)
|
||||
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
|
||||
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
|
||||
|
||||
public bool Detected => ConfidenceScore > 0;
|
||||
|
||||
/// <summary>Everything the event reports; a change re-emits.</summary>
|
||||
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Check against exclusion list
|
||||
if (IsExcludedUser(owner))
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping");
|
||||
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping", repeatKey: "DesktopArrivalDetector.excludedUser");
|
||||
|
||||
// Trace this decision once so it's visible in the backend
|
||||
if (!_excludedUserTraced)
|
||||
@@ -238,7 +238,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -254,7 +254,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
|
||||
// First-poll observability snapshot still fires before we stop polling —
|
||||
// there is value in distinguishing "found on first poll" (Stage already
|
||||
@@ -335,7 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: first-poll emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: first-poll emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -371,7 +371,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wtsErrorCount", _wtsErrorCountSinceStart },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: no-candidate emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: no-candidate emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -393,7 +393,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "startReason", startReason },
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); }
|
||||
catch (Exception ex) { _logger.Debug($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -445,7 +445,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Verbose($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}");
|
||||
_logger.Debug($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}", repeatKey: "DesktopArrivalDetector.oobeCheckFailed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -471,7 +471,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_logger.Info("Stopping ESP and Hello tracker");
|
||||
// Hosts call Stop and then Dispose, which stops again; the second pass finds nothing.
|
||||
if (_helloTracker != null || _shellCoreTracker != null || _provisioningTracker != null || _modernDeploymentTracker != null)
|
||||
_logger.Info("Stopping ESP and Hello tracker");
|
||||
|
||||
DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop());
|
||||
DisposeProvisioningTracker();
|
||||
|
||||
+16
-2
@@ -20,13 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string None = "none";
|
||||
internal const string Unavailable = "unavailable";
|
||||
internal const string DevicePreparation = "device_preparation";
|
||||
private const string FailedSuffix = "_failed";
|
||||
|
||||
internal enum CategoryState { Absent, Running, Done, Failed }
|
||||
|
||||
// ESP order: device preparation, device setup, account setup.
|
||||
private static readonly (string ValueName, string Phase)[] Categories =
|
||||
{
|
||||
("DevicePreparationCategory.Status", "device_preparation"),
|
||||
("DevicePreparationCategory.Status", DevicePreparation),
|
||||
("DeviceSetupCategory.Status", "device_setup"),
|
||||
("AccountSetupCategory.Status", "account_setup"),
|
||||
};
|
||||
@@ -60,7 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
|
||||
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + FailedSuffix;
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Running) return Categories[i].Phase;
|
||||
|
||||
@@ -69,6 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether device preparation can still hold the ESP in a <see cref="Read"/> result: it
|
||||
/// runs or failed, or the phase is unknown (<see cref="None"/>, <see cref="Unavailable"/>).
|
||||
/// False once device preparation is done, whichever later phase the ESP is in.
|
||||
/// </summary>
|
||||
internal static bool DevicePreparationOpen(string phase) =>
|
||||
string.IsNullOrEmpty(phase)
|
||||
|| phase == None
|
||||
|| phase == Unavailable
|
||||
|| phase == DevicePreparation
|
||||
|| phase == DevicePreparation + FailedSuffix;
|
||||
|
||||
/// <summary>
|
||||
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
|
||||
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("EspPolicyProviderProbe: EnrollmentStatusTracking registry key not found");
|
||||
logger?.Debug("EspPolicyProviderProbe: EnrollmentStatusTracking registry key not found", repeatKey: "EspPolicyProviderProbe.noTrackingKey");
|
||||
return EspPolicyProviderSnapshot.Empty;
|
||||
}
|
||||
|
||||
|
||||
+34
-9
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// dispatch-guard exempt), never into decision state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Phase gate: <c>DevicePreparation</c> providers count only while
|
||||
/// <see cref="EspPhaseSnapshot.DevicePreparationOpen"/> holds. Once device preparation is done
|
||||
/// their values hold nothing: a ConfigMgr client installed as a Win32 app registers its
|
||||
/// provider after its own install with <c>InstallationState=1</c>, and nothing completes it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duplicate suppression: in-process one-shot per provider key, plus a
|
||||
/// <see cref="StartupEventGate"/> fingerprint over the sorted set of currently-stalled keys so
|
||||
/// an agent restart into the same stall does not re-report it — while a NEW provider joining
|
||||
@@ -75,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
private readonly TimeSpan _dwell;
|
||||
private readonly Func<string> _espPhaseReader;
|
||||
|
||||
private readonly Dictionary<string, DateTime> _firstSeenIncompleteUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -94,7 +101,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null,
|
||||
TimeSpan? dwell = null)
|
||||
TimeSpan? dwell = null,
|
||||
Func<string>? espPhaseReader = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
@@ -103,6 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
_dwell = dwell ?? TimeSpan.FromMinutes(DwellMinutes);
|
||||
_espPhaseReader = espPhaseReader ?? EspPhaseSnapshot.Read;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe + evaluation round.</summary>
|
||||
@@ -111,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var snapshot = EspPolicyProviderProbe.Read(_logger);
|
||||
var eval = EvaluateStallTransition(
|
||||
snapshot, _firstSeenIncompleteUtc, _firedKeys,
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell,
|
||||
EspPhaseSnapshot.DevicePreparationOpen(_espPhaseReader()));
|
||||
|
||||
_firstSeenIncompleteUtc.Clear();
|
||||
foreach (var kv in eval.UpdatedFirstSeenUtc)
|
||||
@@ -133,6 +143,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
|
||||
/// <param name="nowUtc">Evaluation timestamp.</param>
|
||||
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
|
||||
/// <param name="devicePreparationOpen">Whether device preparation can still hold the ESP (<see cref="EspPhaseSnapshot.DevicePreparationOpen"/>); false drops <c>DevicePreparation</c> providers from arm 1.</param>
|
||||
internal static StallEvaluation EvaluateStallTransition(
|
||||
EspPolicyProviderSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
|
||||
@@ -140,9 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
DateTime? sidecarMissingSinceUtc,
|
||||
bool sidecarMissingAlreadyFired,
|
||||
DateTime nowUtc,
|
||||
TimeSpan dwell)
|
||||
TimeSpan dwell,
|
||||
bool devicePreparationOpen = true)
|
||||
{
|
||||
var result = new StallEvaluation();
|
||||
var result = new StallEvaluation { DevicePreparationOpen = devicePreparationOpen };
|
||||
|
||||
// Missing root key or no registered providers = normal early enrollment: clear all
|
||||
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
|
||||
@@ -152,8 +164,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Arm 1 — a registered provider continuously incomplete.
|
||||
foreach (var provider in snapshot.Providers)
|
||||
{
|
||||
if (provider.IsComplete)
|
||||
continue; // completed → falls out of the dwell map (reset)
|
||||
if (provider.IsComplete || !result.Counts(provider))
|
||||
continue; // completed or past its phase → falls out of the dwell map (reset)
|
||||
|
||||
var firstSeen = firstSeenIncompleteUtc.TryGetValue(provider.Key, out var seen)
|
||||
? seen
|
||||
@@ -197,7 +209,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
|
||||
// new condition joining the stall changes it → re-emitted.
|
||||
var currentlyStalledKeys = snapshot.Providers
|
||||
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
|
||||
.Where(p => !p.IsComplete && eval.Counts(p) && _firedKeys.Contains(p.Key))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
|
||||
@@ -259,9 +271,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
|
||||
|
||||
// The Setup/Apps wait has no timeout; device preparation gives up after the provider's
|
||||
// Timeout node (CSP default 15 min).
|
||||
var setupAppsWait = eval.SidecarMissingStalledForMinutes != null
|
||||
|| eval.NewlyStalled.Any(s => s.Provider.Kind == EspPolicyProviderProbe.KindSetupApps);
|
||||
var message =
|
||||
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
|
||||
" — the ESP waits on its app-tracking providers without any timeout";
|
||||
"ESP policy provider stalled: " + string.Join("; ", messageParts) +
|
||||
(setupAppsWait
|
||||
? " — the ESP waits on its Setup/Apps providers without any timeout"
|
||||
: " — device preparation waits on its providers until their timeout");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
@@ -298,6 +316,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
|
||||
public double? SidecarMissingStalledForMinutes { get; set; }
|
||||
|
||||
/// <summary>The phase gate of this round; the cross-restart fingerprint applies the same filter.</summary>
|
||||
public bool DevicePreparationOpen { get; set; }
|
||||
|
||||
/// <summary>Whether arm 1 considers <paramref name="provider"/> in this round.</summary>
|
||||
public bool Counts(PolicyProviderState provider)
|
||||
=> DevicePreparationOpen || provider.Kind != EspPolicyProviderProbe.KindDevicePreparation;
|
||||
}
|
||||
|
||||
internal readonly struct StalledProvider
|
||||
|
||||
+11
-8
@@ -418,7 +418,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
|
||||
_logger.Trace("Periodic Hello policy check: No WHfB policy found yet - will check again");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,7 +636,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioned successfully - NGC key registered";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED successfully");
|
||||
// A repeat (live plus backfill) is already reported by MarkHelloCompleted.
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED successfully");
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistrationFailed: // 301
|
||||
@@ -644,7 +645,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Error;
|
||||
message = "Windows Hello for Business provisioning failed - NGC key registration error";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED with failure");
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED with failure");
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningWillNotLaunch: // 360
|
||||
@@ -658,7 +659,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning blocked";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
|
||||
if (shouldTriggerHelloCompleted) _logger.Info("Windows Hello provisioning COMPLETED (blocked)");
|
||||
break;
|
||||
|
||||
case EventId_PinStatus: // 376
|
||||
@@ -719,12 +720,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|| eventType == Constants.EventTypes.DeviceRegistrationEvent
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
_logger.Debug($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
EmitHelloPolicyDetectionMismatch(eventType, eventId, timestamp);
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"HelloTracker: HelloCompleted handler threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,12 +971,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ImmediateUpload = shouldTriggerHelloCompleted
|
||||
});
|
||||
|
||||
_logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
_logger.Debug($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
EmitHelloPolicyDetectionMismatch(eventType, eventId, timestamp);
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"HelloTracker: HelloCompleted handler threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -545,7 +545,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
_logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}");
|
||||
_logger.Debug($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}", repeatKey: "ModernDeploymentTracker.nonWhiteGlove509");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
using (record)
|
||||
{
|
||||
found++;
|
||||
_logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
|
||||
_logger.Debug($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
|
||||
ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -97,7 +97,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
hasValues: entries.Any(e => !oobeOnly || IsOobeName(e.Key)), line: line);
|
||||
}
|
||||
|
||||
/// <summary>One agent.log line per present key, one naming the absent keys.</summary>
|
||||
/// <summary>
|
||||
/// One agent.log line per present key, one naming the absent keys. Debug: registry detail
|
||||
/// behind the update page; the oobe_update_state event carries the content.
|
||||
/// </summary>
|
||||
public static void Log(AgentLogger logger, string moment, IReadOnlyList<KeyState> states)
|
||||
{
|
||||
if (logger == null || states == null) return;
|
||||
@@ -110,11 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
absent.Add(state.Path);
|
||||
continue;
|
||||
}
|
||||
logger.Info($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
logger.Debug($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
}
|
||||
|
||||
if (absent.Count > 0)
|
||||
logger.Info($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
logger.Debug($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+8
-11
@@ -514,13 +514,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
changedCount++;
|
||||
bool isNew = !_lastProvisioningJson.ContainsKey(categoryName);
|
||||
// Guard: the raw-JSON interpolation is materialized regardless of log
|
||||
// level (Trace() only drops the string after it is built), and this runs
|
||||
// per changed category every poll — skip the alloc when Trace is off.
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
{
|
||||
_logger.Trace($"CheckProvisioningStatus: {categoryName} — {(isNew ? "NEW" : "CHANGED")} (json length={jsonValue.Length})");
|
||||
_logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}");
|
||||
}
|
||||
// level (Verbose() only drops the string after it is built), and this runs
|
||||
// per changed category every poll — skip the alloc when Verbose is off.
|
||||
if (_logger.LogLevel >= AgentLogLevel.Verbose)
|
||||
_logger.Verbose($"CheckProvisioningStatus: {categoryName} {(isNew ? "new" : "changed")}, raw JSON: {jsonValue}");
|
||||
|
||||
_lastProvisioningJson[categoryName] = jsonValue;
|
||||
// TryFireProvisioningFailure (inside ProcessCategoryStatus) arms the
|
||||
@@ -1007,7 +1004,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName);
|
||||
bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded);
|
||||
|
||||
_logger.Trace($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " +
|
||||
_logger.Verbose($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " +
|
||||
$"isFirstSeen={isFirstSeen}, succeededChanged={categorySucceededChanged}, " +
|
||||
$"subcategories={subcategories.Count} [{string.Join(", ", subcategories.Select(s => $"{s.Name}={s.State}"))}]");
|
||||
|
||||
@@ -1050,7 +1047,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var failureTransitions = transitions.Where(t => t.IsFailure).ToList();
|
||||
if (transitions.Count > 0)
|
||||
{
|
||||
_logger.Trace($"ProcessCategory: {categoryLabel} — subcategory transitions: " +
|
||||
_logger.Debug($"ProcessCategory: {categoryLabel} — subcategory transitions: " +
|
||||
string.Join(", ", transitions.Select(t => $"{t.SubcategoryName}: {t.OldState}→{t.NewState} (failure={t.IsFailure})")));
|
||||
}
|
||||
|
||||
@@ -1115,7 +1112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
var errorCode = TryExtractErrorCode(failedSubcategoryInfo?.StatusText);
|
||||
|
||||
_logger.Info(
|
||||
_logger.Debug(
|
||||
$"Provisioning subcategory failure: {categoryLabel}/{failedSubcategoryName ?? "category-level"} — " +
|
||||
$"escalating as {failureTypeName} (errorCode={errorCode ?? "n/a"})");
|
||||
_logger.Warning($"Provisioning failure detected: {failureTypeName}");
|
||||
@@ -1886,7 +1883,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Data = eventData
|
||||
});
|
||||
|
||||
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})");
|
||||
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})", repeatKey: "ProvisioningStatus.event:" + categoryLabel);
|
||||
}
|
||||
|
||||
private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger)
|
||||
|
||||
+15
-11
@@ -266,24 +266,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
lock (_stateLock)
|
||||
{
|
||||
DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
DisposeAndNull(ref _uninstallWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
}
|
||||
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -913,11 +916,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisposeAndNull(ref RegistryWatcher? w)
|
||||
private static bool DisposeAndNull(ref RegistryWatcher? w)
|
||||
{
|
||||
if (w == null) return;
|
||||
if (w == null) return false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
|
||||
+1
-1
@@ -384,7 +384,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
|
||||
_logger.Debug($"Shell-Core event detected: {eventType} (EventID {eventId})");
|
||||
|
||||
// Set the source-event timestamp BEFORE each event raise so adapters / coordinators
|
||||
// can read it during their synchronous handler. Cleared in finally so a stale value
|
||||
|
||||
+1
-1
@@ -568,7 +568,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}");
|
||||
_logger.Debug($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}", repeatKey: "StallProbeCollector.scanFailed:" + channel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
|
||||
/// without following a reparse point at its final component, and ask the handle where it
|
||||
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
|
||||
/// <c>ImeLogFileIdentity</c> reads a file's identity through the same declarations.
|
||||
/// </summary>
|
||||
internal static class FileNativeMethods
|
||||
{
|
||||
@@ -18,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
|
||||
public const uint FILE_SHARE_READ = 0x1;
|
||||
public const uint FILE_SHARE_WRITE = 0x2;
|
||||
public const uint FILE_SHARE_DELETE = 0x4;
|
||||
|
||||
public const uint OPEN_EXISTING = 3;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class WinHttpNativeMethods
|
||||
{
|
||||
public const int ERROR_FILE_NOT_FOUND = 2;
|
||||
public const uint WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fAutoDetect;
|
||||
public IntPtr lpszAutoConfigUrl;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_PROXY_INFO
|
||||
{
|
||||
public uint dwAccessType;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetIEProxyConfigForCurrentUser(ref WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config);
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetDefaultProxyConfiguration(ref WINHTTP_PROXY_INFO info);
|
||||
|
||||
// WinHTTP allocates the strings of both structs with GlobalAlloc; the caller frees them.
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -344,7 +344,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
// Fetch a short-lived upload URL just before uploading — never stored in config or on disk
|
||||
_logger.Info("Requesting diagnostics upload URL from backend...");
|
||||
_logger.Debug("Requesting diagnostics upload URL from backend...");
|
||||
var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync(
|
||||
_configuration.TenantId,
|
||||
_configuration.SessionId,
|
||||
@@ -708,7 +708,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Expected outcome, not a packaging problem: the subtree is off limits by design.
|
||||
foreach (var refused in refusedDirectories)
|
||||
{
|
||||
_logger.Info($"Diagnostics subfolder skipped by guard: {refused}");
|
||||
_logger.Debug($"Diagnostics subfolder skipped by guard: {refused}");
|
||||
ManifestLine($"SKIPPED DIR (path guard): {refused}");
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
_logger.Verbose($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
}
|
||||
@@ -1066,7 +1066,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
|
||||
_logger.Debug($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
|
||||
|
||||
using var content = new ByteArrayContent(data);
|
||||
content.Headers.Add("x-ms-blob-type", "BlockBlob");
|
||||
@@ -1101,7 +1101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (attempt < maxRetries)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s
|
||||
_logger.Info($"Retrying blob upload in {delay.TotalSeconds}s...");
|
||||
_logger.Debug($"Retrying blob upload in {delay.TotalSeconds}s...");
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var dedupKey = BuildDedupKey(action);
|
||||
if (!_seenActionIds.TryAdd(dedupKey, 0))
|
||||
{
|
||||
_logger.Debug($"ServerActionDispatcher: duplicate action '{action.Type}' ({dedupKey}) — skipping");
|
||||
_logger.Debug($"ServerActionDispatcher: duplicate action '{action.Type}' ({dedupKey}) — skipping", repeatKey: "ServerActionDispatcher.duplicate:" + dedupKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -50,7 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
using (explorerProcess)
|
||||
{
|
||||
sessionId = (uint)explorerProcess.SessionId;
|
||||
logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
|
||||
logger.Debug($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
|
||||
}
|
||||
|
||||
// Get the interactive session token via WTS.
|
||||
@@ -80,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
|
||||
logger.Debug($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
|
||||
|
||||
// Create an environment block for the user (using session-stamped token)
|
||||
if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false))
|
||||
@@ -121,7 +121,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
|
||||
if (cxhProcess != null)
|
||||
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
logger.Debug($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
$"in session {sessionId} — will monitor for exit during retries");
|
||||
}
|
||||
catch { }
|
||||
@@ -162,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
ProcessNativeMethods.CloseHandle(pi.hThread);
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
|
||||
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup...");
|
||||
|
||||
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
|
||||
@@ -190,10 +190,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
|
||||
if (cxhProcess != null)
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
|
||||
{
|
||||
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
logger.Debug("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
cxhProcess.Dispose();
|
||||
cxhProcess = null;
|
||||
System.Threading.Thread.Sleep(500); // brief delay for desktop init
|
||||
@@ -201,7 +201,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
logger.Debug($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
|
||||
}
|
||||
continue;
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Final-shutdown scan — captures any AutoLogon written during the user phase.</summary>
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown AutoLogon scan");
|
||||
_logger.Debug($"{Name}: Running shutdown AutoLogon scan");
|
||||
RunScan("shutdown");
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// </summary>
|
||||
public void AnalyzeAtDeviceSetupComplete()
|
||||
{
|
||||
_logger.Info($"{Name}: Running device-setup-complete AutoLogon scan");
|
||||
_logger.Debug($"{Name}: Running device-setup-complete AutoLogon scan");
|
||||
RunScan("device_setup_complete");
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: scanning prefetch for a post-boot cmd.exe console artifact");
|
||||
_logger.Debug($"{Name}: scanning prefetch for a post-boot cmd.exe console artifact");
|
||||
try { RunScan(); }
|
||||
catch (Exception ex) { _logger.Error($"{Name}: scan failed unexpectedly", ex); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
_logger.Debug($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -114,13 +114,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
_logger.Debug($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup", EnrollmentPhase.Unknown);
|
||||
}
|
||||
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown analysis");
|
||||
_logger.Debug($"{Name}: Running shutdown analysis");
|
||||
|
||||
// At shutdown a user has logged in — their profile folder is expected. Detect logged-in
|
||||
// users via explorer.exe owner (same technique as DesktopArrivalDetector); RunAnalysis
|
||||
|
||||
+5
-5
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis (baseline inventory)");
|
||||
_logger.Debug($"{Name}: Running startup analysis (baseline inventory)");
|
||||
try
|
||||
{
|
||||
// The collection ALWAYS runs — _startupInventory is the in-memory base for the
|
||||
@@ -106,7 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
Constants.EventTypes.SoftwareInventoryAnalysis + ":startup",
|
||||
BuildInventoryFingerprint(_startupInventory)))
|
||||
{
|
||||
_logger.Info($"{Name}: baseline inventory unchanged since last emission — startup events suppressed (restart dedup), delta base kept in memory");
|
||||
_logger.Debug($"{Name}: baseline inventory unchanged since last emission — startup events suppressed (restart dedup), delta base kept in memory");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _part1ShutdownSnapshotEmitted, 1, 0) != 0)
|
||||
{
|
||||
_logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=1) skipped — Part-1 snapshot already emitted (likely by WhiteGloveInventoryTrigger)");
|
||||
_logger.Debug($"{Name}: AnalyzeAtShutdown(whiteGlovePart=1) skipped — Part-1 snapshot already emitted (likely by WhiteGloveInventoryTrigger)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -164,12 +164,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _part2ShutdownSnapshotEmitted, 1, 0) != 0)
|
||||
{
|
||||
_logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=2) skipped — Part-2 snapshot already emitted");
|
||||
_logger.Debug($"{Name}: AnalyzeAtShutdown(whiteGlovePart=2) skipped — Part-2 snapshot already emitted");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
_logger.Debug($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
try
|
||||
{
|
||||
var currentInventory = CollectAndNormalize();
|
||||
|
||||
@@ -62,9 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
|
||||
/// <summary>Stops the collection timer without disposing the collector.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
// Hosts call Stop and then Dispose, which stops again; only a running timer logs.
|
||||
var wasRunning = _timer != null;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
Logger.Info($"[{GetType().Name}] Stopped");
|
||||
if (wasRunning) Logger.Info($"[{GetType().Name}] Stopped");
|
||||
OnAfterStop();
|
||||
}
|
||||
|
||||
@@ -117,7 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
|
||||
// duration of any single slow Collect.
|
||||
if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0)
|
||||
{
|
||||
Logger.Debug($"[{GetType().Name}] tick skipped — previous Collect still running");
|
||||
Logger.Trace($"[{GetType().Name}] tick skipped — previous Collect still running");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
-35
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
|
||||
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
|
||||
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
|
||||
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check WinHTTP proxy
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
|
||||
{
|
||||
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
|
||||
}
|
||||
|
||||
var proxyType = "Direct";
|
||||
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
|
||||
proxyType = "Proxy";
|
||||
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
|
||||
proxyType = "PAC";
|
||||
|
||||
data["proxyType"] = proxyType;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
|
||||
var settings = ProxySettingsReader.Read();
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -209,7 +184,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
default: modeLabel = $"{autopilotMode}"; break;
|
||||
}
|
||||
data["autopilotModeLabel"] = modeLabel;
|
||||
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
|
||||
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})", repeatKey: "DeviceInfo.autopilotMode");
|
||||
}
|
||||
|
||||
// --- Interpret CloudAssignedDomainJoinMethod ---
|
||||
@@ -238,7 +213,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}");
|
||||
_logger.Debug($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,10 +236,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
|
||||
if (isHybridJoin)
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
|
||||
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected", repeatKey: "DeviceInfo.hybridProfile");
|
||||
}
|
||||
|
||||
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
|
||||
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache", repeatKey: "DeviceInfo.policyCache");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -390,7 +365,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{ "detectionRule", detectionRule }
|
||||
});
|
||||
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}", repeatKey: "DeviceInfo.enrollmentType");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -810,7 +785,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
if (hasAnyEspEvidence)
|
||||
{
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}", repeatKey: "DeviceInfo.espConfig");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
}
|
||||
@@ -1004,7 +979,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
var fingerprint = Persistence.StartupEventGate.ComputeFingerprint(data, excludedFields);
|
||||
if (_startupGate.ShouldEmit(eventType, fingerprint)) return true;
|
||||
|
||||
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)");
|
||||
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)", repeatKey: "DeviceInfo.unchanged:" + eventType);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode, bool hasAadJoinedUser, bool isFooUserDetected) CollectAll()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at start)");
|
||||
_logger.Debug("EnrollmentTracker: collecting device info (at start)");
|
||||
|
||||
CollectOSInfoAndBootTime();
|
||||
CollectNetworkAndDnsConfiguration();
|
||||
@@ -113,7 +113,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
public void CollectAtEnrollmentStart()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at enrollment start)");
|
||||
_logger.Debug("EnrollmentTracker: collecting device info (at enrollment start)");
|
||||
|
||||
CollectAadJoinStatus();
|
||||
CollectAutopilotProfile();
|
||||
@@ -134,7 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
public void RefreshEspConfiguration(string trigger)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
|
||||
_logger.Debug($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
|
||||
CollectEspConfiguration();
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
public void CollectAtEnd()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at end)");
|
||||
_logger.Debug("EnrollmentTracker: collecting device info (at end)");
|
||||
|
||||
// BitLocker can be enabled during enrollment via policy
|
||||
CollectBitLockerStatus();
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
values[valueName] = value.ToString();
|
||||
}
|
||||
|
||||
logger.Info($"ZtdEvidence: read {values.Count} values from Diagnostics\\Autopilot");
|
||||
logger.Info($"ZtdEvidence: read {values.Count} values from Diagnostics\\Autopilot", repeatKey: "ZtdEvidence.read");
|
||||
return values.Count > 0 ? values : null;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -130,7 +130,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context);
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}", repeatKey: "LogParserCollector.noFiles:" + rule.RuleId + "|" + filePath);
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
|
||||
return null;
|
||||
@@ -456,7 +456,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
state.CommitLogPosition(rule.RuleId, filePath, endPosition, lineNumberBase + committedLines, head, heldTailStart);
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {fileName}");
|
||||
context.Logger.Verbose($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {fileName}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
@@ -496,7 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
else if (start == LogReadStart.Replaced)
|
||||
{
|
||||
context.Logger.Info($"LogParser rule {rule.RuleId}: {fileName} was replaced since the last read — reading it from the beginning");
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {fileName} was replaced since the last read — reading it from the beginning");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file start changed since the last read (replaced or rewritten) — saved position {known.Position} discarded, reading from the beginning");
|
||||
}
|
||||
|
||||
+8
-8
@@ -261,7 +261,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
interval
|
||||
);
|
||||
_intervalTimers[intervalRule.RuleId] = timer;
|
||||
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
|
||||
_logger.Debug($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
|
||||
}
|
||||
@@ -321,7 +321,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
|
||||
_logger.Debug($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
|
||||
exitRules.Add(rule);
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
foreach (var rule in deferredStartup)
|
||||
{
|
||||
var deferredRule = rule;
|
||||
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
|
||||
_logger.Debug($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
|
||||
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase {phaseName} activated deferred startup rule");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
|
||||
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
_logger.Debug($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
@@ -450,7 +450,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
_logger.Debug($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
@@ -461,7 +461,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})", repeatKey: "GatherRuleExecutor.exec:" + rule.RuleId);
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
|
||||
|
||||
var collectorType = rule.CollectorType?.ToLower();
|
||||
@@ -673,7 +673,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
&& (int)_currentPhase >= (int)fromPhase)
|
||||
{
|
||||
_fromPhaseLatched.Add(rule.RuleId);
|
||||
_logger.Info($"Gather rule {rule.RuleId}: from-phase scope latched at {_currentPhase} (activeFromPhase={rule.ActiveFromPhase})");
|
||||
_logger.Debug($"Gather rule {rule.RuleId}: from-phase scope latched at {_currentPhase} (activeFromPhase={rule.ActiveFromPhase})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,7 +755,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
if (!emit)
|
||||
_logger.Debug($"Gather rule {rule.RuleId}: result unchanged, emit suppressed (on_change)");
|
||||
_logger.Trace($"Gather rule {rule.RuleId}: result unchanged, emit suppressed (on_change)");
|
||||
return emit;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -356,7 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
var oldest = files.OrderBy(file => file.Value.LastTouched).First();
|
||||
files.Remove(oldest.Key);
|
||||
_logger.Info($"GatherRuleSessionState: rule {ruleId} tracks more than {MaxLogFilesPerRule} files — forgot the position of {Path.GetFileName(oldest.Key)}");
|
||||
_logger.Debug($"GatherRuleSessionState: rule {ruleId} tracks more than {MaxLogFilesPerRule} files — forgot the position of {Path.GetFileName(oldest.Key)}");
|
||||
}
|
||||
|
||||
private static string Prefix(string hash) => hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}");
|
||||
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}", repeatKey: "GatherRuleStatePersistence.save:" + SourceName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user