mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08455cc5f9 | |||
| 87c5bf018c | |||
| 0d6c03d64b | |||
| b14866c9a6 | |||
| 25b133d3d0 | |||
| 684167292f | |||
| 6474860a57 | |||
| d23f2eae3e | |||
| fae6aa0b92 | |||
| 43dfb731f1 | |||
| dd5534b0c9 | |||
| 54334de378 | |||
| 9671191eef | |||
| c5a4feb762 | |||
| 2253fca533 | |||
| ff9e79dcc7 | |||
| e54b4585b8 | |||
| 7894d61e0b | |||
| 090f47d4fb | |||
| c2d68b6537 | |||
| 5b0dfaad99 | |||
| 50d29e61e8 | |||
| 4b03ba4e19 | |||
| f0aec031f3 | |||
| 66208b3767 | |||
| c60e07dfd4 | |||
| f564ee6a06 | |||
| ec025686ce | |||
| 821c41e2ba | |||
| ec3eaa6437 | |||
| d36c9a659d | |||
| c27dbf2aa2 | |||
| 28443d11fa | |||
| 0c41b0f449 | |||
| 7799fdb725 | |||
| 0c1a2a4192 | |||
| 0356770ae8 | |||
| 3a9a074be1 | |||
| a3870e7dcc |
+1
-1
Submodule .claude/commands updated: 2ace35ae7d...d48bf40136
@@ -114,7 +114,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -125,6 +125,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -78,6 +78,23 @@ jobs:
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
run: npm ci
|
||||
|
||||
# Star counter for the GitHub star link (utils/githubStars.ts). Baked into the static HTML
|
||||
# so visitors' browsers never call GitHub; stale until the next web deploy. The token stays
|
||||
# in this step, never in the build. Fail-soft: without a number the link has no counter.
|
||||
- name: Read GitHub star count (fail-soft)
|
||||
id: stars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
count=$(gh api "repos/$REPO" --jq .stargazers_count 2>/dev/null || true)
|
||||
if [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
echo "GitHub stars: $count"
|
||||
else
|
||||
echo "::warning::GitHub star count unavailable - the star link renders without a counter"
|
||||
fi
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
@@ -98,6 +115,8 @@ jobs:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
# Star counter from the step above; empty when the lookup failed.
|
||||
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
|
||||
+1
-1
Submodule internal updated: c2cf529c83...d73e9478c3
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -18,10 +18,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -31,6 +35,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -40,6 +47,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -49,6 +59,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+16
-3
@@ -1385,10 +1385,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -1401,10 +1401,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -1414,6 +1418,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1423,6 +1430,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1432,6 +1442,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -203,7 +203,7 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -584,7 +584,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
+6
-3
@@ -41,15 +41,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(
|
||||
logger,
|
||||
"sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
imeLogPathOverride,
|
||||
new LogFilePositionTracker())
|
||||
new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = unrestricted
|
||||
};
|
||||
}
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string>? parameters = null) => new GatherRule
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
_logger, "sess", "tenant", _ => { }, null, new GatherRuleSessionState("sess", null, _logger));
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// An agent restart inside one enrollment session — a new <see cref="GatherRuleExecutor"/> on the
|
||||
/// same state directory — continues where the previous run stopped: logparser rules do not
|
||||
/// replay their log, on_change rules do not re-emit an unchanged result. Regression anchor:
|
||||
/// session c8106021, where every restart replayed the whole HP Image Assistant log (70 events).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorRestartTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly List<GatherRuleExecutor> _executors = new List<GatherRuleExecutor>();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — log targets sit beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-restart-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public GatherRuleExecutorRestartTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var executor in _executors)
|
||||
executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private List<EnrollmentEvent> Events
|
||||
{
|
||||
get { lock (_eventsGate) return _events.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>One agent run: a fresh executor whose startup rules run once. Not disposed — a hard kill.</summary>
|
||||
private GatherRuleExecutor AgentRun(GatherRule rule, string sessionId = "session-a", string? stateDirectory = "default",
|
||||
Action<EnrollmentEvent>? onEvent = null, string? debugLogPath = null)
|
||||
{
|
||||
var executor = new GatherRuleExecutor(
|
||||
sessionId, "tenant",
|
||||
evt =>
|
||||
{
|
||||
onEvent?.Invoke(evt);
|
||||
lock (_eventsGate) _events.Add(evt);
|
||||
},
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
debugLogPath: debugLogPath,
|
||||
stateDirectory: stateDirectory == "default" ? _stateDir.Path : stateDirectory)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
_executors.Add(executor);
|
||||
executor.UpdateRules(new List<GatherRule> { rule });
|
||||
Assert.True(executor.WaitForStartupRules(30));
|
||||
return executor;
|
||||
}
|
||||
|
||||
private static GatherRule LogRule(string path, string id = "hpia-log-collect") => new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "logparser",
|
||||
Target = path,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^(?<status>Update .+)$", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "HPiA-UpdateStatus",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule OnChangeFileRule(string path) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-OC-FILE",
|
||||
Title = "file presence",
|
||||
CollectorType = "file",
|
||||
Target = path,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_file_presence",
|
||||
Enabled = true,
|
||||
EmitMode = "on_change",
|
||||
};
|
||||
|
||||
private string WriteLog(string name, string content)
|
||||
{
|
||||
var path = Path.Combine(_logDir, name);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private int StatusEvents => Events.Count(e => e.EventType == "HPiA-UpdateStatus");
|
||||
|
||||
[Fact]
|
||||
public void Restart_does_not_replay_the_log_and_new_lines_arrive_once()
|
||||
{
|
||||
var path = WriteLog("HP Image Assistant.log", "Update one\nUpdate two\nnoise\n");
|
||||
|
||||
AgentRun(LogRule(path));
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
AgentRun(LogRule(path)); // reboot, nothing new
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
File.AppendAllText(path, "Update three\n");
|
||||
AgentRun(LogRule(path)); // next restart picks up only the new line
|
||||
Assert.Equal(3, StatusEvents);
|
||||
Assert.Equal("Update three", Events.Last(e => e.EventType == "HPiA-UpdateStatus").Data["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_on_disk_right_after_the_run_without_a_clean_shutdown()
|
||||
{
|
||||
var path = WriteLog("hardkill.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path)); // never disposed
|
||||
|
||||
var saved = JsonConvert.DeserializeObject<GatherRuleStateData>(File.ReadAllText(StateFile))!;
|
||||
var position = Assert.Single(saved.LogPositions!);
|
||||
Assert.Equal(new FileInfo(path).Length, position.Position);
|
||||
Assert.Equal("session-a", saved.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_saved_only_after_the_events_went_out()
|
||||
{
|
||||
var path = WriteLog("ordering.log", "Update one\n");
|
||||
var savedDuringEmit = new List<bool>();
|
||||
|
||||
AgentRun(LogRule(path), onEvent: _ => savedDuringEmit.Add(File.Exists(StateFile)));
|
||||
|
||||
Assert.Equal(new[] { false }, savedDuringEmit);
|
||||
Assert.True(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_session_reads_the_log_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("rotated.log", "Update one\nUpdate two\n");
|
||||
AgentRun(LogRule(path), sessionId: "session-a");
|
||||
|
||||
AgentRun(LogRule(path), sessionId: "session-b"); // session rotated on the same device
|
||||
|
||||
Assert.Equal(4, StatusEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_replaced_during_the_reboot_is_read_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("replaced.log", "Update one\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
// The tool writes a new log after the reboot; it is already longer than the old position.
|
||||
File.WriteAllText(path, "Update fresh A\nUpdate fresh B\nUpdate fresh C\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
Assert.Equal(new[] { "Update one", "Update fresh A", "Update fresh B", "Update fresh C" },
|
||||
Events.Where(e => e.EventType == "HPiA-UpdateStatus").Select(e => (string)e.Data["status"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_on_change_result_stays_silent_after_a_restart()
|
||||
{
|
||||
var watched = Path.Combine(_logDir, "marker.txt");
|
||||
|
||||
AgentRun(OnChangeFileRule(watched)); // absent → first result emits
|
||||
AgentRun(OnChangeFileRule(watched)); // reboot, still absent → silent
|
||||
Assert.Single(Events, e => e.EventType == "gather_file_presence");
|
||||
|
||||
File.WriteAllText(watched, "present");
|
||||
AgentRun(OnChangeFileRule(watched)); // changed → emits
|
||||
|
||||
Assert.Equal(2, Events.Count(e => e.EventType == "gather_file_presence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_state_directory_every_run_starts_over_and_writes_nothing()
|
||||
{
|
||||
var path = WriteLog("diagnostic.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
|
||||
Assert.Equal(2, StatusEvents);
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Debug_trace_says_what_was_restored_and_where_reading_continues()
|
||||
{
|
||||
var path = WriteLog("traced.log", "Update one\n");
|
||||
var trace = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
AgentRun(LogRule(path));
|
||||
File.AppendAllText(path, "Update two\n");
|
||||
|
||||
AgentRun(LogRule(path), debugLogPath: trace);
|
||||
|
||||
var text = File.ReadAllText(trace);
|
||||
Assert.Contains("gather state: restored 1 logparser position(s) for 1 rule(s)", text);
|
||||
Assert.Contains("hpia-log-collect | config | logparser position restored: traced.log", text);
|
||||
Assert.Contains("traced.log: continuing at position", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRuleSessionState"/> and <see cref="GatherRuleStatePersistence"/>: what is
|
||||
/// written, what a restart restores, what it refuses, and when the file is (not) written.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleSessionStateTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private GatherRuleSessionState NewState(string sessionId = "session-a") =>
|
||||
new GatherRuleSessionState(sessionId, new GatherRuleStatePersistence(_stateDir.Path, _logger), _logger);
|
||||
|
||||
private static LogFileHead Head(string text) => LogFileHead.FromBytes(Encoding.UTF8.GetBytes(text));
|
||||
|
||||
// ── restore ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Positions_and_emitted_hashes_survive_a_restart_of_the_same_session()
|
||||
{
|
||||
var first = NewState();
|
||||
first.CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("2026-09-28 a.log start"), heldTailStart: -1);
|
||||
first.CommitEmittedHash("RULE-2", "hash-value");
|
||||
|
||||
var second = NewState();
|
||||
|
||||
var position = second.PeekLogPosition("RULE-1", @"C:\Logs\a.log");
|
||||
Assert.NotNull(position);
|
||||
Assert.Equal(120, position!.Position);
|
||||
Assert.Equal(7, position.LineNumber);
|
||||
Assert.True(position.FromPreviousRun);
|
||||
Assert.Equal("hash-value", second.GetEmittedHashes()["RULE-2"]);
|
||||
Assert.StartsWith("restored 1 logparser position(s) for 1 rule(s) and 1 on_change value(s)", second.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_of_another_session_is_ignored()
|
||||
{
|
||||
NewState("session-a").CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("start"), -1);
|
||||
|
||||
var rotated = NewState("session-b");
|
||||
|
||||
Assert.Null(rotated.PeekLogPosition("RULE-1", @"C:\Logs\a.log"));
|
||||
Assert.Contains("saved for session session-a, current session is session-b", rotated.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_file_is_a_first_run_and_corrupt_file_a_fresh_start()
|
||||
{
|
||||
Assert.StartsWith("no saved state", NewState().RestoreSummary);
|
||||
|
||||
File.WriteAllText(StateFile, "not json {{{");
|
||||
var afterCorruption = NewState();
|
||||
|
||||
Assert.StartsWith("saved state discarded", afterCorruption.RestoreSummary);
|
||||
Assert.Empty(afterCorruption.GetEmittedHashes());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tampered_entries_are_dropped_and_the_rest_restored()
|
||||
{
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "OK", Path = @"C:\a.log", Position = 10, LineNumber = 1, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NEG", Path = @"C:\b.log", Position = -5, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "", Path = @"C:\c.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "BIGHEAD", Path = @"C:\d.log", Position = 1, HeadLength = 5000, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NOHEAD", Path = @"C:\e.log", Position = 50, HeadLength = 0 },
|
||||
},
|
||||
OnChangeHashes = new Dictionary<string, string> { ["GOOD"] = "x", ["EMPTY"] = "" },
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", @"C:\a.log"));
|
||||
Assert.Null(state.PeekLogPosition("NEG", @"C:\b.log"));
|
||||
Assert.Null(state.PeekLogPosition("BIGHEAD", @"C:\d.log"));
|
||||
Assert.Null(state.PeekLogPosition("NOHEAD", @"C:\e.log"));
|
||||
Assert.Equal(new[] { "GOOD" }, state.GetEmittedHashes().Keys.ToArray());
|
||||
Assert.Contains("5 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_path_no_real_file_can_have_is_dropped_and_never_stops_the_executor()
|
||||
{
|
||||
// Path APIs throw on these characters under .NET Framework; a throw here would keep
|
||||
// every collector host down at every restart of the session.
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "BAD", Path = "C:\\Logs\\x.|og", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "WILD", Path = "C:\\Logs\\*.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "OK", Path = "C:\\Logs\\ok.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", "C:\\Logs\\ok.log"));
|
||||
Assert.Null(state.PeekLogPosition("BAD", "C:\\Logs\\x.|og"));
|
||||
Assert.Contains("2 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
// ── where reading starts ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reading_continues_while_the_file_head_still_matches()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
|
||||
var start = GatherRuleSessionState.ResolveStart(state.PeekLogPosition("R", @"C:\a.log"), 200,
|
||||
Head("2026-09-28 first line\nsecond line\nthird"), out var how);
|
||||
|
||||
Assert.Equal(30, start);
|
||||
Assert.Equal(LogReadStart.Continue, how);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_shrunk_file_and_a_replaced_file_are_read_from_the_beginning()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
var known = state.PeekLogPosition("R", @"C:\a.log");
|
||||
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 20, Head("2026-09-28 fir"), out var shrunk));
|
||||
Assert.Equal(LogReadStart.Shrunk, shrunk);
|
||||
|
||||
// Replaced and already longer than the old position: only the head tells.
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 500, Head("2026-09-30 a new log entirely, much longer"), out var replaced));
|
||||
Assert.Equal(LogReadStart.Replaced, replaced);
|
||||
|
||||
// Shorter than the recorded head although not shorter than the position.
|
||||
state.CommitLogPosition("S", @"C:\b.log", 5, 1, Head("0123456789abcdef"), -1);
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(state.PeekLogPosition("S", @"C:\b.log"), 8, Head("01234567"), out var cut));
|
||||
Assert.Equal(LogReadStart.Replaced, cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Positions_are_kept_per_rule_for_the_same_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("RULE-A", @"C:\shared.log", 100, 10, Head("x"), -1);
|
||||
|
||||
Assert.Null(state.PeekLogPosition("RULE-B", @"C:\shared.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("RULE-A", @"C:\SHARED.LOG")); // paths compare case-insensitively
|
||||
}
|
||||
|
||||
// ── when the file is written ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_commit_does_not_write_the_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
File.Delete(StateFile);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
File.Delete(StateFile);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_save_is_retried_at_the_next_commit()
|
||||
{
|
||||
var blocked = Path.Combine(_stateDir.Path, "blocked");
|
||||
File.WriteAllText(blocked, "a file where the state directory should be");
|
||||
var state = new GatherRuleSessionState("session-a", new GatherRuleStatePersistence(blocked, _logger), _logger);
|
||||
|
||||
state.CommitEmittedHash("H", "v"); // Directory.CreateDirectory fails
|
||||
File.Delete(blocked);
|
||||
state.CommitEmittedHash("H", "v"); // unchanged, but the earlier save is still owed
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(blocked, GatherRuleStatePersistence.FileName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_state_no_longer_writes()
|
||||
{
|
||||
var state = NewState();
|
||||
state.Close();
|
||||
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_persistence_nothing_is_written()
|
||||
{
|
||||
var state = new GatherRuleSessionState("session-a", null, _logger);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
|
||||
Assert.StartsWith("persistence off", state.RestoreSummary);
|
||||
Assert.Empty(Directory.GetFiles(_stateDir.Path));
|
||||
}
|
||||
|
||||
// ── bounds and concurrency ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void A_rule_forgets_its_least_recently_touched_file_beyond_the_cap()
|
||||
{
|
||||
var state = NewState();
|
||||
for (var i = 0; i < GatherRuleSessionState.MaxLogFilesPerRule; i++)
|
||||
state.CommitLogPosition("R", $@"C:\logs\{i}.log", 1, 1, Head("h"), -1);
|
||||
state.PeekLogPosition("R", @"C:\logs\0.log"); // touched again — survives
|
||||
|
||||
state.CommitLogPosition("R", @"C:\logs\new.log", 1, 1, Head("h"), -1);
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\0.log"));
|
||||
Assert.Null(state.PeekLogPosition("R", @"C:\logs\1.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\new.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parallel_commits_leave_a_consistent_file()
|
||||
{
|
||||
var state = NewState();
|
||||
|
||||
Parallel.For(0, 8, worker =>
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
state.CommitLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log", i + 1, i, Head("h"), -1);
|
||||
state.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log");
|
||||
state.CommitEmittedHash($"RULE-{worker}", "hash-" + i);
|
||||
}
|
||||
});
|
||||
|
||||
var restored = NewState();
|
||||
for (var worker = 0; worker < 8; worker++)
|
||||
{
|
||||
Assert.Equal(50, restored.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log")!.Position);
|
||||
Assert.Equal("hash-49", restored.GetEmittedHashes()[$"RULE-{worker}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="LogLineReader"/>, <see cref="LogTextEncoding"/> and <see cref="LogFileHead"/>:
|
||||
/// exact byte positions, StreamReader-compatible line ends, byte order marks only at offset 0,
|
||||
/// and the code-unit view of UTF-16/UTF-32 files.
|
||||
/// </summary>
|
||||
public sealed class LogLineReaderTests
|
||||
{
|
||||
private static byte[] Bytes(Encoding encoding, string text, bool withBom = true)
|
||||
{
|
||||
var body = encoding.GetBytes(text);
|
||||
return withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
|
||||
}
|
||||
|
||||
private static (List<string> Lines, List<long> EndPositions, List<bool> Terminated) ReadAll(
|
||||
byte[] file, long start = 0, int maxLineBytes = 1024)
|
||||
{
|
||||
var encoding = LogFileHead.FromBytes(file.Take(LogFileHead.MaxBytes).ToArray()).Encoding;
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, maxLineBytes);
|
||||
var lines = new List<string>();
|
||||
var ends = new List<long>();
|
||||
var terminated = new List<bool>();
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
lines.Add(line);
|
||||
ends.Add(reader.Position);
|
||||
terminated.Add(reader.LastLineTerminated);
|
||||
}
|
||||
return (lines, ends, terminated);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_ends_match_StreamReader_including_a_lone_carriage_return()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("one\ntwo\r\nthree\rfour");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "one", "two", "three", "four" }, result.Lines);
|
||||
Assert.Equal(new long[] { 4, 9, 15, 19 }, result.EndPositions);
|
||||
Assert.Equal(new[] { true, true, true, false }, result.Terminated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_after_each_line_is_the_offset_to_resume_from()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("alpha\nbeta\ngamma\n");
|
||||
|
||||
var first = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: first.EndPositions[0]);
|
||||
|
||||
Assert.Equal(new[] { "beta", "gamma" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, resumed.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf8_byte_order_mark_is_skipped_only_at_offset_0()
|
||||
{
|
||||
var file = Bytes(Encoding.UTF8, "first\nsecond\n");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, result.Lines);
|
||||
Assert.Equal(LogTextEncoding.Utf8WithBom, LogFileHead.FromBytes(file).Encoding);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("utf-16le")]
|
||||
[InlineData("utf-16be")]
|
||||
[InlineData("utf-32le")]
|
||||
[InlineData("utf-32be")]
|
||||
public void Unicode_files_are_read_in_their_code_units_from_any_resume_point(string name)
|
||||
{
|
||||
var encoding = name == "utf-16le" ? Encoding.Unicode
|
||||
: name == "utf-16be" ? Encoding.BigEndianUnicode
|
||||
: name == "utf-32le" ? Encoding.UTF32
|
||||
: new UTF32Encoding(bigEndian: true, byteOrderMark: true);
|
||||
var file = Bytes(encoding, "ERROR first\r\nERROR zweite Zeile mit Umlaut äöü\r\n");
|
||||
|
||||
var whole = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: whole.EndPositions[0]);
|
||||
|
||||
Assert.Equal(name, LogFileHead.FromBytes(file).Encoding.Name);
|
||||
Assert.Equal(new[] { "ERROR first", "ERROR zweite Zeile mit Umlaut äöü" }, whole.Lines);
|
||||
Assert.Equal(new[] { "ERROR zweite Zeile mit Umlaut äöü" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, whole.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_without_byte_order_mark_is_utf8_and_ansi_bytes_decode_as_before()
|
||||
{
|
||||
var file = new byte[] { (byte)'M', (byte)'a', (byte)'n', 0xFC, (byte)'\n' }; // "Man" + Windows-1252 'ü'
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(LogTextEncoding.Utf8, LogFileHead.FromBytes(file).Encoding);
|
||||
Assert.Equal(new StreamReader(new MemoryStream(file), Encoding.UTF8).ReadLine(), result.Lines.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carriage_return_at_end_of_file_ends_the_line_and_its_late_line_feed_is_skipped()
|
||||
{
|
||||
var firstWrite = Encoding.UTF8.GetBytes("done\r");
|
||||
var later = Encoding.UTF8.GetBytes("done\r\nnext\n");
|
||||
|
||||
var first = ReadAll(firstWrite);
|
||||
var resumed = ReadAll(later, start: first.EndPositions.Last());
|
||||
|
||||
Assert.Equal(new[] { "done" }, first.Lines);
|
||||
Assert.True(first.Terminated.Single());
|
||||
Assert.Equal(new[] { "next" }, resumed.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlong_line_is_consumed_without_being_kept_and_reading_continues_behind_it()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes(new string('x', 50) + "\nshort\n");
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
var reader = new LogLineReader(stream, LogTextEncoding.Utf8, maxLineBytes: 10);
|
||||
|
||||
Assert.Equal(string.Empty, reader.ReadLine());
|
||||
Assert.True(reader.LastLineTruncated);
|
||||
Assert.Equal(51, reader.Position);
|
||||
Assert.Equal("short", reader.ReadLine());
|
||||
Assert.False(reader.LastLineTruncated);
|
||||
Assert.Null(reader.ReadLine());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_spanning_many_read_buffers_is_returned_whole()
|
||||
{
|
||||
var longLine = new string('y', 200_000);
|
||||
var file = Encoding.UTF8.GetBytes(longLine + "\nend\n");
|
||||
|
||||
var result = ReadAll(file, maxLineBytes: 1_000_000);
|
||||
|
||||
Assert.Equal(new[] { longLine, "end" }, result.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Half_written_utf16_character_at_end_of_file_stays_in_the_unterminated_line()
|
||||
{
|
||||
var full = Bytes(Encoding.Unicode, "ok\r\nhalf");
|
||||
var file = full.Concat(new byte[] { 0x41 }).ToArray(); // first byte of the next UTF-16 unit
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal("ok", result.Lines[0]);
|
||||
Assert.False(result.Terminated.Last());
|
||||
Assert.Equal(file.Length, result.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignDown_snaps_to_code_units_behind_the_byte_order_mark()
|
||||
{
|
||||
Assert.Equal(0, LogTextEncoding.Utf16LittleEndian.AlignDown(1));
|
||||
Assert.Equal(2, LogTextEncoding.Utf16LittleEndian.AlignDown(3));
|
||||
Assert.Equal(6, LogTextEncoding.Utf16LittleEndian.AlignDown(7));
|
||||
Assert.Equal(8, LogTextEncoding.Utf32LittleEndian.AlignDown(11));
|
||||
Assert.Equal(7, LogTextEncoding.Utf8.AlignDown(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Head_hash_of_a_prefix_matches_the_hash_of_the_shorter_file()
|
||||
{
|
||||
var shortFile = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start"));
|
||||
var grown = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start\nmore lines"));
|
||||
|
||||
Assert.Equal(shortFile.Hash(shortFile.Length), grown.Hash(shortFile.Length));
|
||||
Assert.NotEqual(shortFile.Hash(shortFile.Length), grown.Hash(grown.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleSessionState("sess", null, logger),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Reading semantics of <see cref="LogParserCollector"/> across runs on one context: every
|
||||
/// line of a growing log is matched exactly once — no line lost at a maxLines cut, no line
|
||||
/// split by a writer that is mid-line, UTF-16 logs readable after the first run, positions
|
||||
/// kept per rule, line numbers counted from the start of the file.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorReadingTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-reading-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorReadingTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
_context = NewContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext NewContext()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(logger, "sess", "tenant",
|
||||
evt => _events.Add(evt), null, new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
private string LogPath(string name) => Path.Combine(_logDir, name);
|
||||
|
||||
private static GatherRule TextRule(string ruleId, string target, string pattern,
|
||||
string trigger = "interval", int? maxLines = null)
|
||||
{
|
||||
var parameters = new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = "text" };
|
||||
if (maxLines.HasValue) parameters["maxLines"] = maxLines.Value.ToString();
|
||||
return new GatherRule
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = ruleId,
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = parameters,
|
||||
Trigger = trigger,
|
||||
IntervalSeconds = trigger == "interval" ? 30 : (int?)null,
|
||||
OutputEventType = "logparser_reading_test",
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Captured(string group) =>
|
||||
_events.Select(e => e.Data.TryGetValue(group, out var v) ? (string)v : "<missing>").ToList();
|
||||
|
||||
[Fact]
|
||||
public void Two_rules_on_one_file_each_read_the_whole_file()
|
||||
{
|
||||
var path = LogPath("shared.log");
|
||||
File.WriteAllText(path, "ERROR one\nWARN two\nERROR three\n");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(TextRule("RULE-ERRORS", path, "^ERROR (?<what>\\w+)"), _context);
|
||||
collector.Execute(TextRule("RULE-WARNINGS", path, "^WARN (?<what>\\w+)"), _context);
|
||||
|
||||
Assert.Equal(2, _events.Count(e => (string)e.Data["ruleId"] == "RULE-ERRORS"));
|
||||
Assert.Single(_events, e => (string)e.Data["ruleId"] == "RULE-WARNINGS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLines_cut_loses_no_line_across_runs()
|
||||
{
|
||||
var path = LogPath("long.log");
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 1; i <= 30; i++)
|
||||
sb.Append("hit ").Append(i.ToString("D3")).Append(' ').Append('x', 90).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
var rule = TextRule("RULE-LONG", path, "^hit (?<n>\\d{3})", maxLines: 10);
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
for (var run = 0; run < 6; run++)
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
var expected = Enumerable.Range(1, 30).Select(i => i.ToString("D3")).ToList();
|
||||
Assert.Equal(expected, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_written_in_two_parts_is_matched_once_and_whole()
|
||||
{
|
||||
var path = LogPath("partial.log");
|
||||
File.WriteAllText(path, "ERR");
|
||||
var rule = TextRule("RULE-PARTIAL", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "0x1F" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_rule_reads_a_line_still_unfinished_on_its_second_run_once()
|
||||
{
|
||||
var path = LogPath("never-terminated.log");
|
||||
File.WriteAllText(path, "ERROR 0x2A");
|
||||
var rule = TextRule("RULE-UNFINISHED", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // held: the writer may still be in the line
|
||||
collector.Execute(rule, _context); // still unfinished — taken as it is
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Equal(new List<string> { "0x2A" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_shot_rule_reads_an_unfinished_line_now_and_whole_on_its_next_run()
|
||||
{
|
||||
var path = LogPath("startup.log");
|
||||
File.WriteAllText(path, "ok\nERR");
|
||||
var rule = TextRule("RULE-STARTUP", path, "^ERR(?<rest>.*)$", trigger: "startup");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // agent start: the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context); // next agent start
|
||||
|
||||
// Read twice, never split: the second read sees the whole line.
|
||||
Assert.Equal(new List<string> { "", "OR 0x1F" }, Captured("rest"));
|
||||
Assert.Equal(new long[] { 2, 2 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_feed_written_after_a_trailing_carriage_return_is_consumed_without_a_line()
|
||||
{
|
||||
var path = LogPath("cr-at-end.log");
|
||||
File.WriteAllText(path, "hit\r");
|
||||
var rule = TextRule("RULE-CR", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var position = _context.SessionState.PeekLogPosition("RULE-CR", path);
|
||||
Assert.Equal(new FileInfo(path).Length, position!.Position);
|
||||
Assert.Equal(1, position.LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf16_log_appended_after_the_first_run_is_still_matched()
|
||||
{
|
||||
var path = LogPath("transcript.log");
|
||||
File.WriteAllText(path, "ERROR first\r\n", Encoding.Unicode); // UTF-16 LE with BOM
|
||||
var rule = TextRule("RULE-UTF16", path, "^ERROR (?<what>\\w+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write))
|
||||
{
|
||||
var bytes = new UnicodeEncoding(bigEndian: false, byteOrderMark: false).GetBytes("ERROR second\r\n");
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "first", "second" }, Captured("what"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
public void MaxLines_that_is_no_positive_number_falls_back_to_the_default(string maxLines)
|
||||
{
|
||||
var path = LogPath("misconfigured.log");
|
||||
File.WriteAllText(path, "hit 1\nhit 2\n");
|
||||
var rule = TextRule("RULE-MISCONFIGURED", path, "^hit (?<n>\\d)$");
|
||||
rule.Parameters["maxLines"] = maxLines;
|
||||
|
||||
new LogParserCollector().Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "1", "2" }, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_numbers_count_from_the_start_of_the_file()
|
||||
{
|
||||
var path = LogPath("numbers.log");
|
||||
File.WriteAllText(path, "a\nhit\nb\n");
|
||||
var rule = TextRule("RULE-NUMBERS", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "c\nhit\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new long[] { 2, 5 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
tenantId: "tenant",
|
||||
onEventCollected: _ => { },
|
||||
imeLogPathOverride: null,
|
||||
filePositionTracker: new LogFilePositionTracker())
|
||||
sessionState: new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
// Bypass the registry allowlist guard for the synthetic test path.
|
||||
UnrestrictedMode = true
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#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.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// The completion timer a started Hello wizard runs against is the tenant's Hello wait
|
||||
/// (<c>HelloWaitTimeoutSeconds</c>), resolved to 300..3600 s — the same budget the engine's
|
||||
/// hello_safety window spends from the ESP exit, so a tenant allowing an hour is not cut off
|
||||
/// by a fixed 5-min tracker timer while the user is still inside the wizard.
|
||||
/// </summary>
|
||||
public sealed class HelloTrackerCompletionBudgetTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, 300)] // the old default, still stored for many tenants → the built-in window
|
||||
[InlineData(0, 300)]
|
||||
[InlineData(300, 300)]
|
||||
[InlineData(1800, 1800)]
|
||||
[InlineData(3600, 3600)]
|
||||
[InlineData(86400, 3600)] // clamped to the ceiling
|
||||
public void Completion_budget_is_the_resolved_tenant_hello_wait(int configured, int expected)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = NewTracker(tmp, new FakeSignalIngressSink(), configured);
|
||||
|
||||
Assert.Equal(expected, tracker.CompletionTimeoutSecondsForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completion_timeout_event_reports_the_tenant_budget()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
using var tracker = NewTracker(tmp, ingress, helloWaitTimeoutSeconds: 2400);
|
||||
|
||||
tracker.NotifyHelloWizardStarted();
|
||||
Assert.True(tracker.IsCompletionTimerActiveForTest);
|
||||
tracker.TriggerCompletionTimeoutForTest();
|
||||
|
||||
var timeout = ingress.Posted.Single(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.HelloCompletionTimeout);
|
||||
var data = Assert.IsAssignableFrom<IDictionary<string, object>>(timeout.TypedPayload);
|
||||
Assert.Equal(2400, data["timeoutSeconds"]);
|
||||
Assert.Equal("timeout", tracker.HelloOutcome);
|
||||
}
|
||||
|
||||
private static HelloTracker NewTracker(TempDirectory tmp, FakeSignalIngressSink ingress, int helloWaitTimeoutSeconds) =>
|
||||
new HelloTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
|
||||
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -337,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
"Launch powershell executor in machine session",
|
||||
new[] { "context=machine" }
|
||||
};
|
||||
// Wording from the IME decompile (ContentDownloaderDeliveryOptimization, identical 1.97–1.105).
|
||||
// IME wording, identical from 1.97 to 1.105.
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-DO-TIMEOUT-2",
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
/// completion markers (whiteglove.complete, clean-exit) — V1 sessions had only AgentLogs/
|
||||
/// and ImeLogs/, which left forensics blind to decision-engine state and pending uploads.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)] // replaces UserProfileResolver's static state
|
||||
public sealed class DiagnosticsPackageServiceTests
|
||||
{
|
||||
private static AgentConfiguration Cfg(string sessionId = "S1") => new AgentConfiguration
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialises the test classes that replace <see cref="UserProfileResolver"/>'s static state.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class UserProfileResolverCollection
|
||||
{
|
||||
public const string Name = "UserProfileResolver";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The agent starts before anyone has signed in. A lookup in that window must not pin
|
||||
/// "no user" for the rest of the process — the %LOGGED_ON_USER_PROFILE% gather rules and the
|
||||
/// RealmJoin user logs of the diagnostics package would never resolve.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)]
|
||||
public sealed class UserProfileResolverTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime Start = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public void Dispose() => UserProfileResolver.Reset();
|
||||
|
||||
[Fact]
|
||||
public void User_who_signs_in_after_the_first_lookup_is_found_later()
|
||||
{
|
||||
string? signedIn = null;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => signedIn, () => now);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
|
||||
signedIn = @"C:\Users\enduser";
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
|
||||
Assert.Equal(@"C:\Users\enduser", UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_user_is_detected_again_only_after_the_retry_interval()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now += TimeSpan.FromSeconds(10);
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(1, detections);
|
||||
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_clock_stepping_backwards_does_not_delay_the_next_detection()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now -= TimeSpan.FromHours(1); // OOBE time sync
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_found_user_is_kept_without_detecting_again()
|
||||
{
|
||||
var detections = 0;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return @"C:\Users\enduser"; }, () => Start);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(1, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pinned_no_user_stays_null_without_real_detection()
|
||||
{
|
||||
UserProfileResolver.SetForTesting(null!);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
Assert.Null(UserProfileResolver.ExpandCustomTokens(@"%LOGGED_ON_USER_PROFILE%\AppData\Local\x.log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
#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.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// CloudExperienceHost navigation (D-310): one agent.log line per page start/stop and per
|
||||
/// update-page event name, and for the OOBE update page itself (<c>OobeNDUP</c>,
|
||||
/// <c>RebootNDUP</c>) the <c>oobe_update_page</c> telemetry plus the registry state at its live
|
||||
/// start and stop. Descriptions follow the Shell-Core manifest templates; a 62407 value
|
||||
/// (URLs with tenant ids) leaves the tracker only as a boolean, an integer or an HRESULT.
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerBreadcrumbTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string NdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'OobeNDUP'.";
|
||||
private const string EspPageStarted = "CloudExperienceHost Web App Activity started. CXID: 'MDMProgressAfterAADRefactored'.";
|
||||
private const string PageStopped = "CloudExperienceHost Web App Activity stopped. Result: 'success'.";
|
||||
private const string ScanStartedName = "CloudExperienceHost Web App Event 1. Name: 'ExpeditedUpdate_startWUScanStarted'.";
|
||||
private const string NavigateWithTenantUrl =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'Navigate', Value: '{\"httpMethod\":\"GET\",\"url\":\"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/authorize\"}'.";
|
||||
private const string DownloadSucceededWithValue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded', Value: '{\"url\":\"https://login.microsoftonline.com/x\"}'.";
|
||||
private const string CspGateTrue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_isNDUPAllowedByCSPSucceeded', Value: 'true'.";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ShellCoreTracker Build() =>
|
||||
new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => _registry);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState NdupKey(string line) =>
|
||||
new OobeUpdateRegistrySnapshot.KeyState("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP",
|
||||
exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- breadcrumbs ----
|
||||
|
||||
[Fact]
|
||||
public void PageStart_NamesTheCxid()
|
||||
{
|
||||
Assert.Equal("CXH page started: OobeNDUP",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageStop_IsAttributedToTheLastStartedPage()
|
||||
{
|
||||
// 62405 names no page — it ends the one started last.
|
||||
Assert.Equal("CXH page stopped: OobeNDUP (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "OobeNDUP"));
|
||||
Assert.Equal("CXH page stopped: ? (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageEventNames_AreLogged_FromBothEventIds()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_startWUScanStarted",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null));
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsafeEventValues_NeverReachTheLog()
|
||||
{
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.DoesNotContain("microsoftonline", crumb);
|
||||
Assert.DoesNotContain("url", crumb!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEventValues_AreLogged()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_isNDUPAllowedByCSPSucceeded (value=true)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherEventNames_AreNotLogged()
|
||||
{
|
||||
// Ordinary page traffic (Navigate, Visible, ESP progress) stays out of the log.
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, "CommercialOOBE_ESPProgress_Page_Exiting", lastPage: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("CloudExperienceHost Web App Activity started.")]
|
||||
public void PageStartWithoutCxid_IsNoBreadcrumb(string description)
|
||||
{
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, description, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongTokens_AreBounded()
|
||||
{
|
||||
var longCxid = new string('X', 500);
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppStarted, $"CloudExperienceHost Web App Activity started. CXID: '{longCxid}'.", lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.True(crumb!.Length < 200, crumb);
|
||||
Assert.EndsWith("…", crumb);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OobeNDUP", true)]
|
||||
[InlineData("RebootNDUP", true)]
|
||||
[InlineData("EarlyRebootNDUP", true)]
|
||||
[InlineData("ExpeditedUpdate_startWUScanStarted", true)]
|
||||
[InlineData("AADHello", false)]
|
||||
[InlineData("MDMProgressAfterAADRefactored", false)]
|
||||
[InlineData(null, false)]
|
||||
public void UpdatePageTokens_AreRecognized(string? token, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ShellCoreTracker.IsUpdatePageToken(token!));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ update page records ----
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecords_ArePageStartsStopsAndNames_OfTheUpdatePageOnly()
|
||||
{
|
||||
var start = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal("OobeNDUP", start.Page);
|
||||
|
||||
var stop = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "RebootNDUP");
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal("RebootNDUP", stop.Page);
|
||||
Assert.Equal("success", stop.Result);
|
||||
|
||||
var name = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.EventName, name!.CxhEvent);
|
||||
Assert.Equal("ExpeditedUpdate_startWUScanStarted", name.Name);
|
||||
Assert.Null(name.Value);
|
||||
|
||||
// Other pages and names are no update-page records.
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, lastPage: null));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "MDMProgressAfterAADRefactored"));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecord_CarriesOnlyASafeValue()
|
||||
{
|
||||
var unsafeValue = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
Assert.Null(unsafeValue!.Value);
|
||||
|
||||
var gate = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null);
|
||||
Assert.Equal("true", gate!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", "true")]
|
||||
[InlineData("FALSE", "FALSE")]
|
||||
[InlineData("0", "0")]
|
||||
[InlineData("-2147024891", "-2147024891")]
|
||||
[InlineData("0x80240022", "0x80240022")]
|
||||
[InlineData("12345678901", null)] // more than ten digits
|
||||
[InlineData("0x123456789", null)] // more than eight hex digits
|
||||
[InlineData("https://login.microsoftonline.com/x", null)]
|
||||
[InlineData("{\"result\":0}", null)]
|
||||
[InlineData("true and more", null)]
|
||||
[InlineData("", null)]
|
||||
public void SafeValues_AreBooleansIntegersAndHresults(string value, string? expected)
|
||||
{
|
||||
var description = $"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: '{value}'.";
|
||||
|
||||
Assert.Equal(expected, ShellCoreTracker.ExtractSafeValue(description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveUpdatePage_IsReported_WithTheRegistryState_AndRaisesNothing()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
var raised = 0;
|
||||
tracker.EspExited += (_, _) => raised++;
|
||||
tracker.EspFailureDetected += (_, _) => raised++;
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, _) => raised++;
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncInstallCompleted=1") };
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(30), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
Assert.All(pages, p => Assert.Equal(false, Data(p)["backfill"]));
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "update_page_started", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
Assert.All(states, s => Assert.Equal("OobeNDUP", Data(s)["page"]));
|
||||
|
||||
// Telemetry only — no ESP signal, no other event.
|
||||
Assert.Equal(0, raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
Assert.Equal(pages.Count + states.Count, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherPages_AreNotReported()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_ReportsTheUpdatePage_WithItsEventTimes_ButReadsNoRegistry()
|
||||
{
|
||||
// The registry shows the state now, not at the record's time.
|
||||
var reads = 0;
|
||||
using var tracker = new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => { reads++; return new[] { NdupKey("EnableExpeditedUpdate=1") }; });
|
||||
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-4)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(-3)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-2)),
|
||||
});
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(3, pages.Count);
|
||||
Assert.All(pages, p => Assert.Equal(true, Data(p)["backfill"]));
|
||||
Assert.Equal(At.AddMinutes(-4), pages[0].OccurredAtUtc);
|
||||
Assert.Equal(0, reads);
|
||||
// Nothing else: no ESP exit or failure was in the window, so no skipped-records trace.
|
||||
Assert.Equal(3, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentStartAndStop_ReportTheRegistryState_StopOnce()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Start();
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2") };
|
||||
tracker.Stop();
|
||||
tracker.Stop();
|
||||
tracker.Dispose();
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "agent_stop" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerThatNeverStarted_ReportsNothingAtStop()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Dispose();
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
|
||||
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
|
||||
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
|
||||
// enforcement state to 1001 (verified against IME 1.106).
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1002, "installed")]
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#nullable enable
|
||||
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.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The OOBE update registry snapshot (D-310, S4/S5): one readable, bounded line per key, for
|
||||
/// agent.log and the <c>oobe_update_state</c> event. The registry read itself is a thin wrapper.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateRegistrySnapshotTests
|
||||
{
|
||||
private static KeyValuePair<string, object> Entry(string name, object value) => new KeyValuePair<string, object>(name, value);
|
||||
|
||||
[Fact]
|
||||
public void Values_AreFormattedByType()
|
||||
{
|
||||
Assert.Equal("1", OobeUpdateRegistrySnapshot.FormatValue(1));
|
||||
Assert.Equal("5000000000", OobeUpdateRegistrySnapshot.FormatValue(5000000000L));
|
||||
Assert.Equal("Succeeded", OobeUpdateRegistrySnapshot.FormatValue("Succeeded"));
|
||||
Assert.Equal("a|b", OobeUpdateRegistrySnapshot.FormatValue(new[] { "a", "b" }));
|
||||
Assert.Equal("(binary 3 bytes)", OobeUpdateRegistrySnapshot.FormatValue(new byte[] { 1, 2, 3 }));
|
||||
Assert.Equal("(null)", OobeUpdateRegistrySnapshot.FormatValue(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongValues_AreTruncated()
|
||||
{
|
||||
var formatted = OobeUpdateRegistrySnapshot.FormatValue(new string('x', 500));
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.MaxValueLength + 1, formatted.Length);
|
||||
Assert.EndsWith("…", formatted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entries_AreOrderedByName()
|
||||
{
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("ExpeditedUpdateStatus", 2),
|
||||
Entry("EnableExpeditedUpdate", 1),
|
||||
Entry("", "default"),
|
||||
}, oobeOnly: false);
|
||||
|
||||
Assert.Equal("(default)=default, EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyManagerSystemKey_KeepsOnlyOobeValues()
|
||||
{
|
||||
// That key holds every System policy of the device; only the OOBE ones matter here.
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("AllowTelemetry", 1),
|
||||
Entry("AllowOOBEUpdates", 1),
|
||||
Entry("AllowOOBEUpdates_ProviderSet", 1),
|
||||
}, oobeOnly: true);
|
||||
|
||||
Assert.Equal("AllowOOBEUpdates=1, AllowOOBEUpdates_ProviderSet=1", line);
|
||||
Assert.Equal("(no OOBE values)", OobeUpdateRegistrySnapshot.FormatEntries(new[] { Entry("AllowTelemetry", 1) }, oobeOnly: true));
|
||||
Assert.Equal("(no values)", OobeUpdateRegistrySnapshot.FormatEntries(new KeyValuePair<string, object>[0], oobeOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyEntries_AreCapped_AndSaySo()
|
||||
{
|
||||
var entries = Enumerable.Range(0, OobeUpdateRegistrySnapshot.MaxEntries + 5)
|
||||
.Select(i => Entry($"Value{i:000}", i));
|
||||
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(entries, oobeOnly: false);
|
||||
|
||||
Assert.EndsWith("… (+5 more)", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_CountsOnlyOobeValues_OfThePolicyManagerSystemKey()
|
||||
{
|
||||
var policy = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1) }, new string[0]);
|
||||
Assert.True(policy.Exists);
|
||||
Assert.False(policy.HasValues);
|
||||
|
||||
var oobe = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1), Entry("AllowOOBEUpdates", 1) }, new string[0]);
|
||||
Assert.True(oobe.HasValues);
|
||||
Assert.Equal("AllowOOBEUpdates=1", oobe.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_SubkeysAlone_AreNoValues()
|
||||
{
|
||||
var state = OobeUpdateRegistrySnapshot.Describe("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP",
|
||||
new KeyValuePair<string, object>[0], new[] { "Updates" });
|
||||
|
||||
Assert.False(state.HasValues);
|
||||
Assert.Equal("(no values) | subkeys: Updates", state.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_HaveDistinctAliases()
|
||||
{
|
||||
// The aliases are the data keys of oobe_update_state.
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Length,
|
||||
OobeUpdateRegistrySnapshot.Keys.Select(k => k.Alias).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ReturnsEveryKey_FromTheRealRegistry()
|
||||
{
|
||||
var states = OobeUpdateRegistrySnapshot.Read();
|
||||
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Select(k => k.Path), states.Select(s => s.Path));
|
||||
Assert.All(states.Where(s => !s.Exists), s => Assert.False(s.HasValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_ToleratesMissingLoggerOrStates()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(null!, "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", null!);
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
#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.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE update page (D-310): <c>oobe_update_page</c> per
|
||||
/// update-page record and <c>oobe_update_state</c> for the registry behind it — Debug, bounded
|
||||
/// per run, the state only when a key holds values and the content changed.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateTelemetryTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private OobeUpdateTelemetry Build(Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>>? reader = null) =>
|
||||
new OobeUpdateTelemetry("sess-oobe", "tenant-oobe",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
reader ?? (() => _registry));
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState Key(string alias, string? line) =>
|
||||
line == null
|
||||
? new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: false, hasValues: false, line: null)
|
||||
: new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- oobe_update_page ----
|
||||
|
||||
[Fact]
|
||||
public void Page_IsReported_WithItsEventTime_AndFields()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
At.AddMinutes(-3), isBackfill: true);
|
||||
|
||||
var page = Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
Assert.Equal(At.AddMinutes(-3), page.OccurredAtUtc);
|
||||
Assert.Equal("Debug", page.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(page);
|
||||
Assert.Equal("page_stopped", data["cxhEvent"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal("success", data["result"]);
|
||||
Assert.Equal(true, data["backfill"]);
|
||||
Assert.Equal(1, data["occurrence"]);
|
||||
Assert.False(data.ContainsKey("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventName_CarriesItsSafeValue()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
At, isBackfill: false);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage)));
|
||||
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", data["name"]);
|
||||
Assert.Equal("true", data["value"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePageOrName_IsReportedAtMostThreeTimes()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey + 1, pages.Count);
|
||||
Assert.Equal(new object[] { 1, 2, 3 }, pages.Take(3).Select(p => Data(p)["occurrence"]).ToArray());
|
||||
// The repeats do not crowd out the page end.
|
||||
Assert.Equal("page_stopped", Data(pages.Last())["cxhEvent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false);
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ oobe_update_state ----
|
||||
|
||||
[Fact]
|
||||
public void State_WithoutValues_IsNotReported()
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", null), Key("policiesOobe", null) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_ReportsOnlyKeysWithValues_UnderTheirAlias()
|
||||
{
|
||||
_registry = new[]
|
||||
{
|
||||
Key("setupOobeNdup", "EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1"),
|
||||
Key("ndup", null),
|
||||
new OobeUpdateRegistrySnapshot.KeyState("policyManagerSystem", "x", exists: true, hasValues: false, line: "(no OOBE values)"),
|
||||
Key("espSetupPolicy", "InstallQualityUpdates=1"),
|
||||
};
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
|
||||
var state = Assert.Single(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
Assert.Equal("Debug", state.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(state);
|
||||
Assert.Equal("update_page_stopped", data["moment"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal(2, data["keysWithValues"]);
|
||||
Assert.Equal("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1", data["setupOobeNdup"]);
|
||||
Assert.Equal("InstallQualityUpdates=1", data["espSetupPolicy"]);
|
||||
Assert.False(data.ContainsKey("ndup"));
|
||||
Assert.False(data.ContainsKey("policyManagerSystem"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchangedState_IsReportedOnce_AChangeAgain()
|
||||
{
|
||||
var telemetry = Build();
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1") };
|
||||
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1"), Key("setupOobeNdup", "EnableExpeditedUpdate=1") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxStateEventsPerRun + 4; i++)
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", $"ExpeditedUpdateStatus={i}") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateEventsPerRun, ByType(Constants.EventTypes.OobeUpdateState).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongLines_AreTruncated()
|
||||
{
|
||||
_registry = new[] { Key("ndupUpdates", new string('x', 3000)) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
var line = (string)Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdateState)))["ndupUpdates"];
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateLineLength + 1, line.Length);
|
||||
Assert.EndsWith("…", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailingRegistryRead_ReportsNothing_AndDoesNotThrow()
|
||||
{
|
||||
Build(() => throw new UnauthorizedAccessException("denied")).ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
#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.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// ServicingTracker (D-310) — the CBS servicing steps of OS updates from the Setup log. The
|
||||
/// sequence and XML shapes are those Windows 11 writes for a cumulative update installed by
|
||||
/// <c>UpdateAgentLCU</c>: initiate Absent → Staged, Staged reached, initiate Staged → Installed,
|
||||
/// restart required, Installed reached after the restart.
|
||||
/// </summary>
|
||||
public sealed class ServicingTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 25, 18, 52, 37, DateTimeKind.Utc);
|
||||
private const string Kb = "KB5099999";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly ServicingTracker _tracker;
|
||||
private long _nextRecordId = 1200;
|
||||
|
||||
public ServicingTrackerTests()
|
||||
{
|
||||
_tracker = NewTracker(stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ServicingTracker NewTracker(string? stateDirectory) =>
|
||||
new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Process(
|
||||
int eventId,
|
||||
DateTime at,
|
||||
string? initialState = null,
|
||||
string? targetState = "Installed",
|
||||
string? errorCode = null,
|
||||
string? client = "UpdateAgentLCU",
|
||||
string? package = Kb,
|
||||
long? recordId = null,
|
||||
ServicingTracker? tracker = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
(tracker ?? _tracker).ProcessEvent(
|
||||
eventId: eventId,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: at,
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Servicing() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.WindowsUpdateServicing).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void CumulativeUpdateSequence_ListsEveryStep_AndReportsTheEngineSteps()
|
||||
{
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Absent", targetState: "Staged");
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(4), targetState: "Staged", errorCode: "0x0");
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0.AddMinutes(29), initialState: "Staged", targetState: "Installed");
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0.AddMinutes(33), targetState: "Installed", errorCode: "0x0");
|
||||
// Written after the restart.
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(41), targetState: "Installed", errorCode: "0x0");
|
||||
|
||||
var rows = Servicing();
|
||||
Assert.Equal(
|
||||
new[] { "initiating", "state_reached", "initiating", "reboot_required", "state_reached" },
|
||||
rows.Select(s => (string)Data(s)["step"]).ToArray());
|
||||
Assert.All(rows, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(rows, s => Assert.Equal("ServicingWatcher", s.Payload![SignalPayloadKeys.Source]));
|
||||
Assert.All(rows, s => Assert.Equal(Kb, Data(s)["package"]));
|
||||
Assert.All(rows, s => Assert.Equal("UpdateAgentLCU", Data(s)["client"]));
|
||||
// ErrorCode 0x0 is success, not a decoded HRESULT.
|
||||
Assert.All(rows, s => Assert.False(Data(s).ContainsKey("hresult")));
|
||||
Assert.Equal("Absent", Data(rows[0])["initialState"]);
|
||||
Assert.Equal("Staged", Data(rows[0])["targetState"]);
|
||||
Assert.Equal(T0.AddMinutes(33), rows[3].OccurredAtUtc);
|
||||
Assert.Contains("needs a restart", rows[3].Payload![SignalPayloadKeys.Message]);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.StagingStarted, OsUpdateSteps.Staged, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.RebootRequired, OsUpdateSteps.Installed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Kb, a.Update));
|
||||
Assert.Equal(T0.AddMinutes(29), _osActivity[2].At);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CbsTask")]
|
||||
[InlineData("DISM Package Manager Provider")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void NonOsClients_AreCountedNotListed(string? client)
|
||||
{
|
||||
// CBS maintenance writes hundreds of these in seconds; language packs and features on
|
||||
// demand installed by apps are not the OOBE update either.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Staged", targetState: "Installed",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
Process(ServicingTracker.EventId_StateReached, T0, targetState: "Installed", errorCode: "0x0",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
var counts = _tally.Snapshot().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
Assert.Equal(1, counts["servicing_other_1"]);
|
||||
Assert.Equal(1, counts["servicing_other_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsUpdateAgentClient_IsAnOsClient()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, client: "WindowsUpdateAgent", errorCode: "0x0");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeFailed_EmitsWarning_WithDecodedHResult()
|
||||
{
|
||||
Process(ServicingTracker.EventId_ChangeFailed, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("failed", data["step"]);
|
||||
Assert.Equal("0x800F0922", data["hresult"]);
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", data["hresultSymbol"]);
|
||||
Assert.Contains("FAILED", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartiallyInstalled_IsAFailure_AndSaysSo()
|
||||
{
|
||||
Process(ServicingTracker.EventId_PartiallyInstalled, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("failed", Data(s)["step"]);
|
||||
Assert.Contains("partially installed", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeTowardsAnotherState_IsListed_ButIsNoEngineStep()
|
||||
{
|
||||
// Removing a superseded package is servicing, not progress of the update install.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Installed", targetState: "Absent");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledRecord_KeepsItsEventTime()
|
||||
{
|
||||
var earlier = T0.AddMinutes(-20);
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, earlier, initialState: "Absent", targetState: "Staged", isBackfill: true);
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal(earlier, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
Assert.Equal(earlier, Assert.Single(_osActivity).At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_SkipsEmittedRecordsAfterRestart()
|
||||
{
|
||||
var first = NewTracker(_tmp.Path);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: first);
|
||||
|
||||
var second = NewTracker(_tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: second, isBackfill: true);
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(8), errorCode: "0x0", recordId: 1207, tracker: second);
|
||||
|
||||
Assert.Equal(new[] { "reboot_required", "state_reached" }, Servicing().Select(s => (string)Data(s)["step"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordDeliveredTwice_IsListedOnce()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Single(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_ReadsBothCbsPayloadShapes()
|
||||
{
|
||||
const string initiate =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>1</EventID><Version>0</Version><Level>0</Level><Task>1</Task><Opcode>0</Opcode><Keywords>0x8000000000000000</Keywords>" +
|
||||
"<TimeCreated SystemTime='2026-09-25T19:21:42.4486782Z'/><EventRecordID>1203</EventRecordID><Channel>Setup</Channel><Computer>DESKTOP-TEST</Computer>" +
|
||||
"<Security UserID='S-1-5-18'/></System><UserData><CbsPackageInitiateChanges xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'>" +
|
||||
"<PackageIdentifier>KB5099999</PackageIdentifier><InitialPackageState>5064</InitialPackageState><InitialPackageStateTextized>Staged</InitialPackageStateTextized>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><Client>UpdateAgentLCU</Client>" +
|
||||
"</CbsPackageInitiateChanges></UserData></Event>";
|
||||
const string changeState =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>4</EventID><TimeCreated SystemTime='2026-09-25T19:26:07.1381462Z'/><EventRecordID>1205</EventRecordID><Channel>Setup</Channel></System>" +
|
||||
"<UserData><CbsPackageChangeState xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'><PackageIdentifier>KB5099999</PackageIdentifier>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><ErrorCode>0x0</ErrorCode>" +
|
||||
"<Client>UpdateAgentLCU</Client></CbsPackageChangeState></UserData></Event>";
|
||||
|
||||
var a = ServicingTracker.ParseUserData(initiate);
|
||||
Assert.Equal("KB5099999", a["PackageIdentifier"]);
|
||||
Assert.Equal("Staged", a["InitialPackageStateTextized"]);
|
||||
Assert.Equal("Installed", a["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("UpdateAgentLCU", a["client"]); // case-insensitive
|
||||
Assert.False(a.ContainsKey("ErrorCode"));
|
||||
|
||||
var b = ServicingTracker.ParseUserData(changeState);
|
||||
Assert.Equal("Installed", b["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("0x0", b["ErrorCode"]);
|
||||
Assert.False(b.ContainsKey("InitialPackageStateTextized"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_MalformedOrWithoutUserData_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
Assert.Empty(ServicingTracker.ParseUserData(null));
|
||||
Assert.Empty(ServicingTracker.ParseUserData(""));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<not-xml"));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<Event><System><EventID>1</EventID></System></Event>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// "Reboot required" is written right before the update's restart: its engine signal
|
||||
// must be on its way before the watermark tells the next run's backfill to skip it.
|
||||
var path = System.IO.Path.Combine(_tmp.Path, ServicingTracker.WatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = System.IO.File.Exists(path));
|
||||
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", tracker: tracker);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.True(System.IO.File.Exists(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
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.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// A tenant-extended Hello wait runs up to 60 min — the default session_stalled threshold. While
|
||||
/// it is pending the session waits on a bounded timer that resolves it either way, so the stall
|
||||
/// probe holds the report back instead of flagging the session Stalled; once the wait is over
|
||||
/// and the session is still idle the report fires, and real activity drops it. The collector
|
||||
/// runs with no scan sources, so only the session_stalled decision is exercised.
|
||||
/// </summary>
|
||||
public sealed class StallProbeHelloWaitGuardTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
private const double IdleAtStallThreshold = 60;
|
||||
|
||||
[Fact]
|
||||
public void Session_stalled_is_held_back_while_the_hello_wait_is_pending()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => true);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
|
||||
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_back_report_fires_once_the_hello_wait_is_over_and_the_session_is_still_idle()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var pending = true;
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
|
||||
// hello_safety resolved (a deadline fire is no activity, so no ResetProbes): the next
|
||||
// tick emits the held-back report — once.
|
||||
pending = false;
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 2);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_activity_drops_the_held_back_report()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var pending = true;
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
pending = false;
|
||||
collector.ResetProbes();
|
||||
collector.CheckAndRunProbes(1);
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
|
||||
// A new idle window reaches the threshold again — the fire-once report is still unused.
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_hello_wait_probe_session_stalled_fires_as_before()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: null);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_hello_wait_probe_never_costs_the_stall_report()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => throw new InvalidOperationException("probe down"));
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
private static StallProbeCollector NewCollector(TempDirectory tmp, FakeSignalIngressSink ingress, Func<bool>? helloWaitPending) =>
|
||||
new StallProbeCollector(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
|
||||
thresholdsMinutes: new[] { 60 },
|
||||
traceIndices: Array.Empty<int>(),
|
||||
sources: Array.Empty<string>(),
|
||||
sessionStalledAfterProbeIndex: 1,
|
||||
helloWaitPending: helloWaitPending);
|
||||
|
||||
private static int StalledCount(FakeSignalIngressSink ingress) =>
|
||||
ingress.Posted.Count(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.SessionStalled);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateClassifier (D-310) — decides which Windows Update client events are listed one
|
||||
/// by one (OS updates) and which are only counted (Store, Defender, everything else). The
|
||||
/// titles are the shapes the WU client writes on Windows 11 GA and Insider builds.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)")]
|
||||
[InlineData("2026-09 Cumulative Update Preview for Windows 11, version 24H2 for x64-based Systems (KB5099998)")]
|
||||
[InlineData("2026-09 Dynamic Update for Windows 11, version 25H2 for x64-based Systems (KB5099997)")]
|
||||
[InlineData("2026-09 Servicing Stack Update for Windows 11, version 25H2 for x64-based Systems (KB5099996)")]
|
||||
[InlineData("Windows 11 Insider Preview Quality Update (26220.9568)")]
|
||||
[InlineData("Windows 11 Insider Preview Feature Update (26220.9022)")]
|
||||
[InlineData("Windows 11, version 25H2 Upgrade")]
|
||||
[InlineData("2026-09 .NET Framework Security Update (KB5099995)")]
|
||||
[InlineData("2026-08 .NET Framework Preview Update (KB5099994)")]
|
||||
[InlineData("Windows Malicious Software Removal Tool x64 - v5.145 (KB890830)")]
|
||||
public void WindowsAndDotNetFrameworkUpdates_AreOs(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)")]
|
||||
[InlineData("Update for Microsoft Defender Antivirus antimalware platform - KB4052623 (Version 4.18.26080.4) - Current Channel (Staged)")]
|
||||
// Names "Windows" and "Update" — Defender must still win.
|
||||
[InlineData("Windows 10/11 - Update for Microsoft Defender for Endpoint - KB5005292 (Version 10.8838.26060.15013) - Current Channel (Staged)")]
|
||||
public void DefenderUpdates_AreDefender(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Defender, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8")]
|
||||
[InlineData("9NBLGGH4NNS1-Microsoft.DesktopAppInstaller")]
|
||||
// Upper-case "WINDOWS" inside the package name must not make it an OS update.
|
||||
[InlineData("9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR")]
|
||||
[InlineData("9WZDNCRFHWKN-MICROSOFT.WINDOWSSOUNDRECORDER")]
|
||||
[InlineData("ApplicationSet-9WZDNCRFJ3Q2-Microsoft.BingWeather")]
|
||||
public void StoreProductTitles_AreStore(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{855e8a7c-ecb4-4ca3-b045-1dfa50104289}")]
|
||||
[InlineData("{855E8A7C-ECB4-4CA3-B045-1DFA50104289}")]
|
||||
[InlineData("855e8a7c-ecb4-4ca3-b045-1dfa50104289")]
|
||||
public void StoreServiceGuid_IsStore_WhateverTheTitle(string serviceGuid)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", serviceGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherServiceGuid_LeavesTheTitleInCharge()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", "{7971f918-a847-4430-9279-4a52d1efe18d}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)")]
|
||||
[InlineData("PowerShell v7.6.6 (x64)")]
|
||||
[InlineData("Remote help 5.2.1040.0")]
|
||||
[InlineData("Microsoft Edge-Stable Channel Version 140 Update for x64 based Edge-Chromium (Build 140.0.3485.54)")]
|
||||
[InlineData("Microsoft .NET 8.0.20 Security Update for x64 Client (KB5099993)")]
|
||||
[InlineData("Windows Feature Experience Pack 1000.26100.234.0")]
|
||||
[InlineData("KB5099999")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void EverythingElse_IsOther(string? title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Other, WindowsUpdateClassifier.Classify(title!, serviceGuid: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
+409
-61
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -9,55 +10,81 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateTracker — surfaces quality/cumulative update activity during OOBE from the
|
||||
/// WindowsUpdateClient/Operational channel. Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// WindowsUpdateClient provider: install events from the System log, scan/download events from
|
||||
/// the Operational channel (D-310). Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// test seam (no real EventRecord, which is abstract + Windows-only), mirroring the
|
||||
/// ModernDeploymentTracker test pattern.
|
||||
/// ModernDeploymentTracker test pattern. Update titles are the shapes the provider writes.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 8, 10, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
private const string StoreApp = "9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR";
|
||||
private const string DefenderIntelligence = "Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly WindowsUpdateTracker _system;
|
||||
private readonly WindowsUpdateTracker _operational;
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public WindowsUpdateTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: null); // in-memory dedup for most tests
|
||||
// In-memory dedup for most tests.
|
||||
_system = NewTracker(WindowsUpdateTracker.SystemChannel, stateDirectory: null);
|
||||
_operational = NewTracker(channel: null, stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private void Emit(int eventId, string? errorCode = null, string? updateTitle = "Cumulative Update KB5099999", long? recordId = null)
|
||||
private WindowsUpdateTracker NewTracker(string? channel, string? stateDirectory) =>
|
||||
new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
channel: channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Emit(
|
||||
WindowsUpdateTracker tracker,
|
||||
int eventId,
|
||||
string? updateTitle = Lcu,
|
||||
string? errorCode = null,
|
||||
long? recordId = null,
|
||||
string? serviceGuid = null,
|
||||
string? updateCount = null,
|
||||
DateTime? at = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: 4,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: At,
|
||||
timeCreatedUtc: at ?? At,
|
||||
updateTitle: updateTitle,
|
||||
updateGuid: "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
updateRevisionNumber: "200",
|
||||
errorCode: errorCode,
|
||||
formattedDescription: null,
|
||||
isBackfill: false);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
@@ -70,10 +97,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
private int Counted(string key) =>
|
||||
_tally.Snapshot().Where(kv => kv.Key == key).Select(kv => kv.Value).SingleOrDefault();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install events (System log)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FailureEvent_EmitsWindowsUpdateFailed_WithDecodedHResult_AndImmediateUpload()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
var s = Assert.Single(failed);
|
||||
@@ -83,37 +117,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("0x80240022", data["hresult"]);
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", data["hresultSymbol"]);
|
||||
Assert.Equal("Cumulative Update KB5099999", data["updateTitle"]);
|
||||
Assert.Equal(Lcu, data["updateTitle"]);
|
||||
Assert.Equal(20, data["wuEventId"]);
|
||||
Assert.Equal("system", data["wuChannel"]);
|
||||
Assert.Equal("install", data["wuPhase"]);
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, data["updateClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessEvent_EmitsWindowsUpdateSucceeded_InfoSeverity()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
var ok = ByType(Constants.EventTypes.WindowsUpdateSucceeded);
|
||||
var s = Assert.Single(ok);
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("installed", Data(s)["wuPhase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAndDownloadEvents_EmitDebugStarted()
|
||||
public void StartedAndDownloadEvents_EmitInfoStarted_WithTheirPhase()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
// Info, not Debug: with only OS updates listed they are the visible progress of a
|
||||
// long OOBE update.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
|
||||
var started = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(2, started.Count);
|
||||
Assert.All(started, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(started, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.Equal(new[] { "install", "download" }, started.Select(s => (string)Data(s)["wuPhase"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_ProcessedTwice_EmitsOnce()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
@@ -125,8 +166,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// with a higher RecordId can be processed first. An older, never-emitted backfill event
|
||||
// (lower RecordId) must NOT be suppressed as "already processed" — these early pre-agent
|
||||
// OOBE updates are the whole point of the feature. A high-water-mark dedup dropped them.
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
@@ -138,53 +179,212 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// time, not at agent start (the ctor's default UtcNow). InformationalEventPost forwards
|
||||
// EnrollmentEvent.Timestamp as the signal's OccurredAtUtc, which drives the timeline entry.
|
||||
var eventTime = new DateTime(2026, 7, 8, 7, 5, 0, DateTimeKind.Utc); // well before `At`
|
||||
_tracker.ProcessEvent(
|
||||
eventId: WindowsUpdateTracker.EventId_InstallFailure,
|
||||
level: 4,
|
||||
recordId: 7,
|
||||
timeCreatedUtc: eventTime,
|
||||
updateTitle: "KB5099999",
|
||||
updateGuid: null,
|
||||
updateRevisionNumber: null,
|
||||
errorCode: "0x80240022",
|
||||
formattedDescription: null,
|
||||
isBackfill: true);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022",
|
||||
recordId: 7, at: eventTime, isBackfill: true);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal(eventTime, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeRecordId_IsNeverDeduped()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan/download events (Operational channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
public void Downloaded_EmitsInfoStarted_WithDownloadedPhase()
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded);
|
||||
|
||||
var first = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("downloaded", Data(s)["wuPhase"]);
|
||||
Assert.Equal("operational", Data(s)["wuChannel"]);
|
||||
}
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: true);
|
||||
[Fact]
|
||||
public void DownloadFailure_OfAnOsUpdate_EmitsWarning_WithDownloadPhase()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007");
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("download", data["wuPhase"]);
|
||||
Assert.Equal("0x80248007", data["hresult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_EmitsOnlyChangedCounts_AndCountsTheRepeats()
|
||||
{
|
||||
foreach (var count in new[] { "3", "3", "5", "5", "0" })
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: count);
|
||||
|
||||
var found = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(new[] { "3", "5", "0" }, found.Select(s => (string)Data(s)["updateCount"]).ToArray());
|
||||
Assert.All(found, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(found, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.All(found, s => Assert.False(Data(s).ContainsKey("updateClass")));
|
||||
Assert.Contains("scan found 3 update(s)", found[0].Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
Assert.Empty(_osActivity); // a scan result is not an update step
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFoundEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: i.ToString());
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFoundEmissions, ByType(Constants.EventTypes.WindowsUpdateStarted).Count);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_EmitsEachErrorCodeOnce()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x80072EE2");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401C");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
Assert.Equal(2, failed.Count);
|
||||
Assert.All(failed, s => Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(failed, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFailedEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: $"0x8024000{i}");
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFailedEmissions, ByType(Constants.EventTypes.WindowsUpdateFailed).Count);
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification — only OS updates are listed, the rest is counted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreApp, null, WindowsUpdateTracker.EventId_InstallSuccess, "store_19")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_Downloaded, "store_41")]
|
||||
[InlineData(DefenderIntelligence, null, WindowsUpdateTracker.EventId_InstallStarted, "defender_43")]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)", null, WindowsUpdateTracker.EventId_DownloadStarted, "other_44")]
|
||||
// A real download failure names no update at all; it must not reach ANALYZE-DEV-004 as
|
||||
// a failed OS update.
|
||||
[InlineData("", null, WindowsUpdateTracker.EventId_DownloadFailed, "other_31")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_InstallFailure, "store_20")]
|
||||
public void NonOsUpdates_AreCountedNotEmitted(string title, string? serviceGuid, int eventId, string expectedKey)
|
||||
{
|
||||
var tracker = WindowsUpdateTracker.IsSystemChannelEventId(eventId) ? _system : _operational;
|
||||
Emit(tracker, eventId, updateTitle: title, serviceGuid: serviceGuid, errorCode: "0x80248007");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
Assert.Equal(1, Counted(expectedKey));
|
||||
// The census still sees the channel working.
|
||||
Assert.Equal(1, tracker.EmittedThisRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecord_DeliveredTwice_IsCountedOnce()
|
||||
{
|
||||
// Live watcher and backfill overlap: the seen-set dedups counted records too.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
|
||||
Assert.Equal(1, Counted("store_19"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSteps_AreReportedToTheEngineCallback()
|
||||
{
|
||||
var t0 = At;
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted, at: t0);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded, at: t0.AddMinutes(4));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted, at: t0.AddMinutes(5));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, at: t0.AddMinutes(31));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x800F0922", at: t0.AddMinutes(32));
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007", at: t0.AddMinutes(33));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.DownloadStarted, OsUpdateSteps.Downloaded, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.Installed, OsUpdateSteps.Failed, OsUpdateSteps.Failed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Lcu, a.Update));
|
||||
Assert.Equal(t0.AddMinutes(31), _osActivity[3].At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingCallback_NeverCostsTheEvent()
|
||||
{
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
"s", "t", new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
onOsUpdateActivity: (step, update, at) => throw new InvalidOperationException("boom"));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels and XPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DefaultEventIds_FollowTheProviderManifest()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, _system.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, _system.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, _operational.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, _operational.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(16, true)]
|
||||
[InlineData(19, true)]
|
||||
[InlineData(20, true)]
|
||||
[InlineData(24, true)]
|
||||
[InlineData(27, true)]
|
||||
[InlineData(28, true)]
|
||||
[InlineData(32, true)]
|
||||
[InlineData(33, true)]
|
||||
[InlineData(43, true)]
|
||||
[InlineData(44, true)]
|
||||
[InlineData(212, true)]
|
||||
[InlineData(218, true)]
|
||||
[InlineData(25, false)]
|
||||
[InlineData(26, false)]
|
||||
[InlineData(31, false)]
|
||||
[InlineData(41, false)]
|
||||
[InlineData(15, false)]
|
||||
[InlineData(219, false)]
|
||||
public void IsSystemChannelEventId_MatchesTheManifest(int eventId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WindowsUpdateTracker.IsSystemChannelEventId(eventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_ContainsAllTargetedIds_Ordered()
|
||||
{
|
||||
@@ -192,6 +392,113 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("*[System[(EventID=19 or EventID=20 or EventID=43 or EventID=44)]]", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_SharedSystemLog_FiltersTheProvider_AndTheLookback()
|
||||
{
|
||||
// Verified against a real System log: the provider clause keeps the other providers'
|
||||
// EventIDs 19/20/43/44 out of the shared channel.
|
||||
var ids = new HashSet<int> { 44, 19, 20, 43 };
|
||||
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44)]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName));
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44) and TimeCreated[timediff(@SystemTime) <= 3600000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName, lookbackMs: 3600000));
|
||||
Assert.Equal(
|
||||
"*[System[(EventID=25 or EventID=26) and TimeCreated[timediff(@SystemTime) <= 60000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(new HashSet<int> { 26, 25 }, providerName: null, lookbackMs: 60000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watermark
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
{
|
||||
var first = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(first, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Emit(second, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_IsPerChannel()
|
||||
{
|
||||
// RecordIds are per channel: System record 500 and Operational record 500 are two
|
||||
// different events and must not dedup each other — before or after a restart.
|
||||
Emit(NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path), WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
Emit(NewTracker(null, _tmp.Path), WindowsUpdateTracker.EventId_Downloaded, recordId: 500);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.WatermarkStateFileName));
|
||||
|
||||
var system = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
var operational = NewTracker(null, _tmp.Path);
|
||||
system.LoadWatermark();
|
||||
operational.LoadWatermark();
|
||||
Emit(system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
Emit(operational, WindowsUpdateTracker.EventId_Downloaded, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecords_WriteNoStateFile()
|
||||
{
|
||||
// A Store/Defender burst must not cost a state-file write per record; only emitted
|
||||
// records move the persisted watermark.
|
||||
var tracker = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 600);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, updateTitle: DefenderIntelligence, recordId: 601);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName)));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 602);
|
||||
Assert.Equal(602L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// A restart's backfill skips a persisted record, so the record's engine signal must be
|
||||
// on its way before the watermark reaches the disk — never the other way round.
|
||||
var path = Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = File.Exists(path));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 700);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.Equal(700L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
private long ReadWatermark(string fileName) =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(_tmp.Path, fileName)))["LastRecordId"]!.Value<long>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing and HRESULT decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ExtractsNamedFields_NamespaceAgnostic()
|
||||
{
|
||||
@@ -212,6 +519,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.True(data.ContainsKey("UPDATETITLE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ReadsTheScanFields()
|
||||
{
|
||||
// Event 26 (scan found N) and 25 (scan failed) carry no update title.
|
||||
const string xml =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
|
||||
"<System><EventID>26</EventID></System>" +
|
||||
"<EventData>" +
|
||||
"<Data Name='updateCount'>3</Data>" +
|
||||
"<Data Name='serviceGuid'>{8b24b027-1dee-babb-9a95-3517dfb9c552}</Data>" +
|
||||
"</EventData></Event>";
|
||||
|
||||
var data = WindowsUpdateTracker.ParseEventData(xml);
|
||||
Assert.Equal("3", data["updateCount"]);
|
||||
Assert.Equal("{8b24b027-1dee-babb-9a95-3517dfb9c552}", data["serviceGuid"]);
|
||||
Assert.False(data.ContainsKey("updateTitle"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_MalformedXml_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
@@ -303,10 +628,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("21=1,25=3", data["wuClientCensus"]);
|
||||
Assert.Equal("200=2", data["updateOrchestratorCensus"]);
|
||||
Assert.Equal("19,20,43,44", data["targetedEventIds"]);
|
||||
Assert.Equal("25,26,31,41", data["targetedEventIds"]);
|
||||
Assert.False(data.ContainsKey("censusTruncated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_ListsTheTargetedIdsOfEveryChannel()
|
||||
{
|
||||
// The host runs the census on the Operational instance; the evidence must still say
|
||||
// the System-log install IDs were watched too.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 0, otherTargetedEventIds: new[] { 44, 19, 20, 43 });
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
Assert.Equal("19,20,25,26,31,41,43,44", Data(s)["targetedEventIds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_BuildUnchanged_EmitsNothing()
|
||||
{
|
||||
@@ -320,17 +658,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
[Fact]
|
||||
public void Census_TargetedEventsWereEmittedThisRun_EmitsNothing()
|
||||
{
|
||||
// The watcher captured the update through its normal path — no blind spot to report.
|
||||
// The watcher captured activity through its normal path — no blind spot to report.
|
||||
// Any class counts: a counted Store update proves the channel is read as well.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
tracker.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 9, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_Downloaded, updateTitle: StoreApp, recordId: 9);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_AnotherChannelEmitted_EmitsNothing()
|
||||
{
|
||||
// The System-log watcher saw the install — the update was not missed.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 1);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_DisabledByConfig_EmitsNothing()
|
||||
{
|
||||
|
||||
+56
@@ -153,6 +153,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_fires_callback_once_with_the_stage_still_non_terminal()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal, yet the agent must end the session — the callback carries the state.
|
||||
using var rig = new Rig();
|
||||
DecisionState? captured = null;
|
||||
var callCount = 0;
|
||||
var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
|
||||
onTerminal: state =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
captured = state;
|
||||
});
|
||||
|
||||
var builder = new DecisionStateBuilder(sut.CurrentState)
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StepIndex = sut.CurrentState.StepIndex + 1,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 1),
|
||||
};
|
||||
var stopped = builder.Build();
|
||||
var step = new DecisionStep(stopped, new DecisionTransition(
|
||||
stepIndex: stopped.StepIndex, sessionTraceOrdinal: 1, signalOrdinalRef: 1, occurredAtUtc: At,
|
||||
trigger: "DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired",
|
||||
fromStage: SessionStage.SessionStarted, toStage: SessionStage.EspAccountSetup,
|
||||
taken: true, deadEndReason: null, reducerVersion: "2.0.0.0"), Array.Empty<DecisionEffect>());
|
||||
sut.ApplyStep(step, Sig(1, At));
|
||||
|
||||
// A follow-up step must not fire it again.
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 2, at: At.AddSeconds(1)),
|
||||
Sig(2, At.AddSeconds(1)));
|
||||
|
||||
Assert.Equal(1, callCount);
|
||||
Assert.Equal(SessionStage.EspAccountSetup, captured!.Stage);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_on_load_does_not_refire_the_callback()
|
||||
{
|
||||
// The orchestrator ends a recovered stopped-waiting session itself at start.
|
||||
using var rig = new Rig();
|
||||
var callCount = 0;
|
||||
var recovered = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 7),
|
||||
}.Build();
|
||||
|
||||
var sut = rig.Build(recovered, onTerminal: _ => Interlocked.Increment(ref callCount));
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 8, at: At), Sig(8, At));
|
||||
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_callback_null_is_supported()
|
||||
{
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
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>
|
||||
/// The production factory hands its state directory to the gather executor. Without it every
|
||||
/// unit test of the gather state passes while the agent keeps replaying logs after each reboot —
|
||||
/// the one line in <see cref="DefaultComponentFactory"/> is the whole feature.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // the startup rule executes on the shared ThreadPool
|
||||
public sealed class DefaultComponentFactoryGatherStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — the log sits beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "factory-gather-state-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public DefaultComponentFactoryGatherStateTests() => Directory.CreateDirectory(_logDir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gather_host_built_by_the_factory_persists_logparser_positions_in_the_state_directory()
|
||||
{
|
||||
var log = Path.Combine(_logDir, "vendor.log");
|
||||
File.WriteAllText(log, "Update one\n");
|
||||
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = "https://example",
|
||||
TenantId = "t",
|
||||
SessionId = "s",
|
||||
UnrestrictedMode = true,
|
||||
},
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new List<GatherRule>
|
||||
{
|
||||
new GatherRule
|
||||
{
|
||||
RuleId = "vendor-log",
|
||||
Title = "vendor log",
|
||||
CollectorType = "logparser",
|
||||
Target = log,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^Update", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "vendor_update",
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
stateDirectory: _stateDir.Path,
|
||||
startupEventGate: null);
|
||||
|
||||
var hosts = factory.CreateCollectorHosts(
|
||||
sessionId: "s",
|
||||
tenantId: "t",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
whiteGloveSealingPatternIds: Array.Empty<string>(),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc)),
|
||||
telemetrySpool: null,
|
||||
timelineEvents: null).Hosts;
|
||||
try
|
||||
{
|
||||
hosts.OfType<GatherRuleExecutorHost>().Single().Start();
|
||||
|
||||
var stateFile = Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
Assert.True(SpinWait.SpinUntil(() => File.Exists(stateFile), TimeSpan.FromSeconds(10)),
|
||||
"the gather host did not write its state into the factory's state directory");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// <summary>
|
||||
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
|
||||
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
|
||||
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
|
||||
/// signals. Regression anchor: session 32312a32 — a phase_change rule on
|
||||
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
|
||||
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
|
||||
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
|
||||
|
||||
@@ -174,6 +174,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovered_stopped_waiting_state_ends_the_session_at_start()
|
||||
{
|
||||
// D-310: a crash between "the engine stopped waiting" and the termination must not
|
||||
// leave the session running — the restart ends it the same way, with the engine's reason.
|
||||
using var rig = new Rig();
|
||||
Directory.CreateDirectory(rig.StateDir);
|
||||
|
||||
var stoppedState = DecisionState.CreateInitial("S1", "T1")
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(9)
|
||||
.Build()
|
||||
.ToBuilder();
|
||||
stoppedState.StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 42);
|
||||
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(stoppedState.Build());
|
||||
|
||||
var sut = rig.Build();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
EnrollmentTerminatedEventArgs? captured = null;
|
||||
sut.Terminated += (_, e) => { captured = e; fired.Set(); };
|
||||
|
||||
sut.Start();
|
||||
Assert.True(fired.Wait(5000), "Terminated did not fire for the recovered stopped-waiting state.");
|
||||
|
||||
Assert.Equal(EnrollmentTerminationReason.StoppedWaiting, captured!.Reason);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StopReason);
|
||||
Assert.Equal(SessionStage.EspAccountSetup.ToString(), captured.StageName);
|
||||
Assert.Contains("60 minutes", captured.Details);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
// ================================================================= Sonderfall 2: Segment-Quarantine
|
||||
|
||||
[Fact]
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#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.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateWatcherHost (D-310) — routes every configured WU EventID to the channel the
|
||||
/// provider manifest writes it to, reports counted activity once, and feeds Windows/.NET update
|
||||
/// steps to the decision engine unless <c>OobeUpdatePhaseMode</c> is Off. The event-log
|
||||
/// watchers themselves are not armed here; the trackers' ProcessEvent seams drive the host.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateWatcherHostTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 1, 9, 30, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private WindowsUpdateWatcherHost Build(bool osUpdateSignalsEnabled, bool servicingWatcherEnabled = true) =>
|
||||
new WindowsUpdateWatcherHost(
|
||||
sessionId: "sess-wuhost",
|
||||
tenantId: "tenant-wuhost",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
targetedEventIds: new[] { 19, 20, 43, 44 },
|
||||
backfillLookbackMinutes: 0,
|
||||
stateDirectory: _tmp.Path,
|
||||
operationalEventIds: new[] { 25, 26, 31, 41 },
|
||||
servicingWatcherEnabled: servicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: osUpdateSignalsEnabled);
|
||||
|
||||
private void Wu(WindowsUpdateTracker tracker, int eventId, string title, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, 4, _nextRecordId++, at, title, updateGuid: null, updateRevisionNumber: null,
|
||||
errorCode: null, formattedDescription: null, isBackfill: false);
|
||||
|
||||
private void Cbs(ServicingTracker tracker, int eventId, string client, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, _nextRecordId++, at, "KB5099999", initialState: null, targetState: "Installed",
|
||||
errorCode: "0x0", client: client, isBackfill: false);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> OsUpdateActivity() =>
|
||||
_sink.Posted.Where(p => p.Kind == DecisionSignalKind.OsUpdateActivity).ToList();
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
// ---------------------------------------------------------------- routing ----
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_Defaults_SplitByManifest()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 19, 20, 43, 44 }, new[] { 25, 26, 31, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, system);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_MisplacedIds_EndUpOnTheirRealChannel()
|
||||
{
|
||||
// An old config that lists 41 under the targeted IDs, or 20 under the operational
|
||||
// ones, must still watch the channel that carries them.
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 41, 19 }, new[] { 20, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20 }, system);
|
||||
Assert.Equal(new[] { 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_NothingConfigured_WatchesNothing()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(null, Array.Empty<int>());
|
||||
|
||||
Assert.Empty(system);
|
||||
Assert.Empty(operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_BuildsOneTrackerPerChannel_AndTheServicingTracker()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, host.SystemTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, host.SystemTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, host.OperationalTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, host.OperationalTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.NotNull(host.ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServicingWatcherDisabled_BuildsNoServicingTracker()
|
||||
{
|
||||
Assert.Null(Build(osUpdateSignalsEnabled: false, servicingWatcherEnabled: false).ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- engine signal behind flag ----
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsDisabled_ListsTheUpdate_ButPostsNoEngineSignal()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateServicing));
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_PostsOsUpdateActivity_FromBothSources()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
var wuAt = DateTime.SpecifyKind(At, DateTimeKind.Unspecified);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, wuAt);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
var signals = OsUpdateActivity();
|
||||
Assert.Equal(2, signals.Count);
|
||||
|
||||
var wu = signals[0];
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, wu.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("wu", wu.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal(Lcu, wu.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("WindowsUpdateWatcher", wu.SourceOrigin);
|
||||
Assert.Equal(EvidenceKind.Raw, wu.Evidence.Kind);
|
||||
Assert.Equal("os-update-activity:wu", wu.Evidence.Identifier);
|
||||
Assert.Equal(At, wu.OccurredAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, wu.OccurredAtUtc.Kind);
|
||||
Assert.Equal(1, wu.KindSchemaVersion);
|
||||
|
||||
var cbs = signals[1];
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, cbs.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("servicing", cbs.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal("KB5099999", cbs.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("ServicingWatcher", cbs.SourceOrigin);
|
||||
Assert.Equal("os-update-activity:servicing", cbs.Evidence.Identifier);
|
||||
Assert.Equal(At.AddMinutes(20), cbs.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_NonOsActivity_NeverReachesTheEngine()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.OperationalTrackerForTest!, WindowsUpdateTracker.EventId_Downloaded,
|
||||
"Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_StateReached, "CbsTask", At);
|
||||
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- activity summary ----
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_IsEmittedOnce_WhenTheHostStops()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_InitiatingChanges, "CbsTask", At);
|
||||
|
||||
// The termination path stops the host explicitly and disposes it afterwards.
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var summary = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
Assert.Equal("Debug", summary.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Contains("3 event(s)", summary.Payload![SignalPayloadKeys.Message]);
|
||||
var data = (IReadOnlyDictionary<string, object>)summary.TypedPayload!;
|
||||
Assert.Equal(2, data["store_19"]);
|
||||
Assert.Equal(1, data["servicing_other_1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_NothingCounted_EmitsNothing()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, Lcu, At);
|
||||
|
||||
host.Stop();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-3
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
|
||||
|
||||
Assert.Single(sink.Posted);
|
||||
var posted = sink.Posted[0];
|
||||
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
|
||||
|
||||
var payload = sink.Posted[0].Payload;
|
||||
Assert.NotNull(payload);
|
||||
@@ -73,6 +74,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(300)] // the default — the payload stays exactly what it was before the key existed
|
||||
[InlineData(30)] // the old default, still stored for many tenants
|
||||
[InlineData(0)]
|
||||
public void Hello_wait_not_extending_the_built_in_window_is_not_stamped(int helloWaitTimeoutSeconds)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
|
||||
|
||||
Assert.DoesNotContain(SignalPayloadKeys.HelloWaitTimeoutSeconds, (IDictionary<string, string>)sink.Posted[0].Payload!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1800, "1800")]
|
||||
[InlineData(3600, "3600")]
|
||||
[InlineData(86400, "3600")] // the effective (clamped) value is what the engine should arm
|
||||
public void Extended_hello_wait_is_stamped_as_its_effective_value(int helloWaitTimeoutSeconds, string expected)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
|
||||
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.HelloWaitTimeoutSeconds]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Shadow")] // no config value — the default mode
|
||||
[InlineData("Shadow", "Shadow")]
|
||||
[InlineData("active", "Active")] // case-insensitive
|
||||
[InlineData("Off", "Off")]
|
||||
[InlineData("Enforce", "Shadow")] // unknown — changes no decision, still measures
|
||||
public void Oobe_update_phase_mode_is_stamped_at_every_start_normalized(string? configured, string expected)
|
||||
{
|
||||
// D-310: stamped every time, so a later Off/Shadow can take Active away mid-session.
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
sink, HelloWaitTimeout.DefaultSeconds, NewLogger(tmp.Path), configured);
|
||||
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.OobeUpdatePhaseMode]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swallows_sink_exceptions()
|
||||
{
|
||||
@@ -82,7 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger));
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
+37
@@ -933,6 +933,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_propagates_stopped_waiting_reason_and_cause_in_agent_shutting_down_data()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user (no sign-in after the OOBE update).
|
||||
// The backend classifies this shutdown honestly — it keys on the reason tag.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.EspAccountSetup }.Build();
|
||||
|
||||
rig.Build().Handle(sender: null!, new EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason.StoppedWaiting,
|
||||
EnrollmentTerminationOutcome.TimedOut,
|
||||
SessionStage.EspAccountSetup.ToString(),
|
||||
EndUtc,
|
||||
details: StoppedWaitingReasons.Describe(StoppedWaitingReasons.OobeUpdateNoSignIn),
|
||||
stopReason: StoppedWaitingReasons.OobeUpdateNoSignIn));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal("stopped_waiting", (string)data!["reason"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, (string)data["stopReason"]);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_omits_stop_reason_for_other_shutdowns()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.AwaitingHello }.Build();
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.AwaitingHello));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.False(data!.ContainsKey("stopReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_includes_uptime_and_agent_version_in_agent_shutting_down()
|
||||
{
|
||||
|
||||
@@ -157,12 +157,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
public double ReplaySpeedFactor { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Wait time in seconds after ESP exit before marking Hello as skipped.
|
||||
/// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
|
||||
/// Default: 30 seconds (reasonable for systems under load).
|
||||
/// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
|
||||
/// Tenant Hello wait in seconds after the ESP exit, merged from RemoteConfig. Stamped on
|
||||
/// <c>EnrollmentFactsObserved</c> for the decision engine's hello_safety window; the Hello
|
||||
/// tracker gets the same remote value through the collector configuration. Both resolve it
|
||||
/// through <see cref="HelloWaitTimeout.EffectiveSeconds"/> (at least the built-in 300 s,
|
||||
/// at most 3600 s). Default: 300 seconds.
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum consecutive authentication failures (401/403) before the agent shuts down.
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
|
||||
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
|
||||
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
|
||||
/// decompiled build). Within one process every file shares the zone belief, so a local
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified against
|
||||
/// the IME build). Within one process every file shares the zone belief, so a local
|
||||
/// timestamp places the line in the service era whose local range contains it. Disabled
|
||||
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
|
||||
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ using System.Collections.Generic;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks file read positions for incremental log file reading.
|
||||
/// In-memory only - resets on agent restart, which is acceptable since
|
||||
/// we start fresh per enrollment session.
|
||||
/// Tracks the IME log tracker's file read positions for incremental log file reading.
|
||||
/// The bookmarks survive agent restarts through the tracker's own state file
|
||||
/// (<see cref="GetAllPositions"/> / <see cref="RestorePosition"/>); the freshness timestamps do not.
|
||||
/// </summary>
|
||||
public class LogFilePositionTracker
|
||||
{
|
||||
|
||||
+1
-1
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30,
|
||||
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds,
|
||||
bool modernDeploymentWatcherEnabled = true,
|
||||
int modernDeploymentLogLevelMax = 3,
|
||||
bool modernDeploymentBackfillEnabled = true,
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-restart dedup for event-log watchers that backfill a channel on start. RecordIds are
|
||||
/// per channel, so every watched channel owns one instance with its own state file.
|
||||
/// <para>
|
||||
/// Two boundaries, deliberately separate: the restart watermark is the highest RecordId a
|
||||
/// PRIOR run emitted (loaded once, immutable for this run); the seen-set holds what THIS run
|
||||
/// processed. A single high-water mark would be wrong because the live watcher is armed before
|
||||
/// the backfill runs: a live record with a higher RecordId can arrive first and would then
|
||||
/// make the backfill skip every older, never-emitted record.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EventRecordWatermark
|
||||
{
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _label;
|
||||
private readonly object _lock = new object();
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
private long _restartWatermark = -1;
|
||||
private long _maxEmittedRecordId = -1;
|
||||
|
||||
/// <param name="stateDirectory">Directory for the state file; null keeps the dedup in memory only.</param>
|
||||
/// <param name="stateFileName">File name inside <paramref name="stateDirectory"/>.</param>
|
||||
/// <param name="label">Watcher name for log lines.</param>
|
||||
public EventRecordWatermark(string stateDirectory, string stateFileName, AgentLogger logger, string label)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_label = label ?? "EventLog";
|
||||
_stateFilePath = string.IsNullOrEmpty(stateDirectory) || string.IsNullOrEmpty(stateFileName)
|
||||
? null
|
||||
: Path.Combine(Environment.ExpandEnvironmentVariables(stateDirectory), stateFileName);
|
||||
}
|
||||
|
||||
/// <summary>Highest RecordId emitted by a prior run; -1 when none was persisted.</summary>
|
||||
public long RestartWatermark
|
||||
{
|
||||
get { lock (_lock) return _restartWatermark; }
|
||||
}
|
||||
|
||||
/// <summary>True when the record was already emitted, by a prior run or by this one.</summary>
|
||||
public bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/> for this run. True on first sight (the
|
||||
/// caller processes it), false when it was already processed. Records without a RecordId
|
||||
/// (-1) are always claimed, never tracked. Claiming persists nothing — only
|
||||
/// <see cref="MarkEmitted"/> does, so a record that is counted instead of emitted costs no
|
||||
/// state-file write (CBS maintenance writes hundreds of servicing records in seconds).
|
||||
/// </summary>
|
||||
public bool TryClaim(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId > _restartWatermark && _seenThisRun.Add(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="recordId"/> when it raises the emitted maximum, so a restarted
|
||||
/// agent's backfill skips it. Called after the record's event and engine signal were
|
||||
/// handed to the ingress: a crash before that re-emits the record instead of losing it.
|
||||
/// The ingress makes them durable only when its worker appends them to the SignalLog —
|
||||
/// normally milliseconds later; a process killed inside that window (e.g. by a reboot)
|
||||
/// loses the record, and the backfill does not bring it back. Counted records above the
|
||||
/// persisted maximum are counted again after a restart, which only touches the activity
|
||||
/// summary.
|
||||
/// </summary>
|
||||
public void MarkEmitted(long recordId)
|
||||
{
|
||||
if (recordId < 0) return;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_lock)
|
||||
{
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = recordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) Persist(toPersist);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (_stateFilePath == null || !File.Exists(_stateFilePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(File.ReadAllText(_stateFilePath));
|
||||
if (state == null) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"{_label} watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist(long recordId)
|
||||
{
|
||||
if (_stateFilePath == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_stateFilePath));
|
||||
var json = JsonConvert.SerializeObject(
|
||||
new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow },
|
||||
Formatting.Indented);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, _stateFilePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-9
@@ -47,7 +47,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string HResult_UserSkippedHello = "0x801C044F";
|
||||
|
||||
internal const int HelloCompletionTimeoutSeconds = 300;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
|
||||
@@ -81,6 +80,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly int _helloWaitTimeoutSeconds;
|
||||
|
||||
// How long a started Hello wizard may take to reach a terminal event (300/301/362): the
|
||||
// tenant's Hello wait resolved through HelloWaitTimeout (300..3600 s). The engine's
|
||||
// hello_safety window spends the same budget from the ESP exit, so this timer never cuts
|
||||
// a user off inside the wizard before the tenant's budget is used up.
|
||||
private readonly int _helloCompletionTimeoutSeconds;
|
||||
|
||||
private EventLogWatcher _udrWatcher;
|
||||
private EventLogWatcher _helloForBusinessWatcher;
|
||||
private System.Threading.Timer _policyCheckTimer;
|
||||
@@ -121,13 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30)
|
||||
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
|
||||
_helloCompletionTimeoutSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -1094,7 +1100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (policyKnownEnabled)
|
||||
{
|
||||
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
|
||||
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
|
||||
$"wizard not yet visible, starting long completion timer ({_helloCompletionTimeoutSeconds}s)");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
@@ -1105,7 +1111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
|
||||
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
|
||||
$"Hello policy is enabled, waiting up to {_helloCompletionTimeoutSeconds}s for wizard",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
|
||||
@@ -1205,11 +1211,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_isHelloCompleted) return;
|
||||
if (_helloCompletionTimer != null) return;
|
||||
|
||||
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
|
||||
_logger.Info($"Starting Hello completion timer ({_helloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
|
||||
_helloCompletionTimer = new System.Threading.Timer(
|
||||
OnHelloCompletionTimeout,
|
||||
null,
|
||||
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
|
||||
TimeSpan.FromSeconds(_helloCompletionTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
}
|
||||
|
||||
@@ -1230,7 +1236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
|
||||
_logger.Warning($"Hello completion timeout ({_helloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
|
||||
StopHelloCompletionTimerLocked();
|
||||
@@ -1243,10 +1249,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
|
||||
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {_helloCompletionTimeoutSeconds}s",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
|
||||
{ "timeoutSeconds", _helloCompletionTimeoutSeconds },
|
||||
{ "helloWizardStarted", _helloWizardStarted }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
@@ -1304,6 +1310,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null);
|
||||
internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null);
|
||||
|
||||
internal int CompletionTimeoutSecondsForTest => _helloCompletionTimeoutSeconds;
|
||||
internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } }
|
||||
internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } }
|
||||
internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } }
|
||||
|
||||
+1
-1
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
/// <summary>StateMessageEnforcementState bands (verified against IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
if (state >= 1000 && state < 2000) return "success";
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the registry state behind the OOBE quality update (D-310, S4/S5): the NDUP state
|
||||
/// Windows keeps while the update page runs, and both policy paths of the ESP setting "Install
|
||||
/// Windows quality updates". <see cref="OobeUpdateTelemetry"/> logs it and reports it as
|
||||
/// <c>oobe_update_state</c>. Read-only; the values are update-state flags and policy values,
|
||||
/// no user data.
|
||||
/// </summary>
|
||||
internal static class OobeUpdateRegistrySnapshot
|
||||
{
|
||||
/// <summary>The snapshot's keys, each with the alias it carries in <c>oobe_update_state</c>.</summary>
|
||||
internal static readonly (string Alias, string Path)[] Keys =
|
||||
{
|
||||
("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP"),
|
||||
("setupOobeNdupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP\Updates"),
|
||||
("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP"),
|
||||
("ndupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP\Updates"),
|
||||
("espSetupPolicy", @"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup\Policy"),
|
||||
("policyManagerSystem", PolicyManagerSystemKey),
|
||||
("policiesOobe", @"SOFTWARE\Policies\Microsoft\Windows\OOBE"),
|
||||
};
|
||||
|
||||
// Carries every System policy of the device; only the OOBE ones belong in this snapshot.
|
||||
internal const string PolicyManagerSystemKey = @"SOFTWARE\Microsoft\PolicyManager\current\device\System";
|
||||
|
||||
internal const int MaxEntries = 30;
|
||||
internal const int MaxValueLength = 200;
|
||||
|
||||
/// <summary>One key of the snapshot: whether it exists, whether it holds values, and its line.</summary>
|
||||
internal sealed class KeyState
|
||||
{
|
||||
public KeyState(string alias, string path, bool exists, bool hasValues, string line)
|
||||
{
|
||||
Alias = alias;
|
||||
Path = path;
|
||||
Exists = exists;
|
||||
HasValues = hasValues;
|
||||
Line = line;
|
||||
}
|
||||
|
||||
public string Alias { get; }
|
||||
public string Path { get; }
|
||||
public bool Exists { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the key holds at least one value — for <see cref="PolicyManagerSystemKey"/>
|
||||
/// one with OOBE in its name. Subkeys alone are no values.
|
||||
/// </summary>
|
||||
public bool HasValues { get; }
|
||||
|
||||
/// <summary><see cref="FormatEntries"/> plus the subkey names; null when the key is absent.</summary>
|
||||
public string Line { get; }
|
||||
}
|
||||
|
||||
/// <summary>Reads every key from the 64-bit view. Throws on a registry failure; the caller is fail-soft.</summary>
|
||||
public static IReadOnlyList<KeyState> Read()
|
||||
{
|
||||
var states = new List<KeyState>(Keys.Length);
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
{
|
||||
foreach (var (alias, path) in Keys)
|
||||
{
|
||||
using (var key = hklm.OpenSubKey(path))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
states.Add(new KeyState(alias, path, exists: false, hasValues: false, line: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var entries = key.GetValueNames()
|
||||
.Select(name => new KeyValuePair<string, object>(name, key.GetValue(name)))
|
||||
.ToList();
|
||||
states.Add(Describe(alias, path, entries, key.GetSubKeyNames()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/// <summary>The state of a present key from its values and subkey names.</summary>
|
||||
internal static KeyState Describe(string alias, string path, IReadOnlyList<KeyValuePair<string, object>> entries, string[] subKeys)
|
||||
{
|
||||
var oobeOnly = string.Equals(path, PolicyManagerSystemKey, StringComparison.OrdinalIgnoreCase);
|
||||
var line = FormatEntries(entries, oobeOnly);
|
||||
if (subKeys != null && subKeys.Length > 0)
|
||||
line += $" | subkeys: {string.Join(", ", subKeys.Take(MaxEntries))}";
|
||||
return new KeyState(alias, path, exists: true,
|
||||
hasValues: entries.Any(e => !oobeOnly || IsOobeName(e.Key)), line: line);
|
||||
}
|
||||
|
||||
/// <summary>One agent.log line per present key, one naming the absent keys.</summary>
|
||||
public static void Log(AgentLogger logger, string moment, IReadOnlyList<KeyState> states)
|
||||
{
|
||||
if (logger == null || states == null) return;
|
||||
|
||||
var absent = new List<string>();
|
||||
foreach (var state in states)
|
||||
{
|
||||
if (!state.Exists)
|
||||
{
|
||||
absent.Add(state.Path);
|
||||
continue;
|
||||
}
|
||||
logger.Info($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
}
|
||||
|
||||
if (absent.Count > 0)
|
||||
logger.Info($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>name=value</c> pairs ordered by name, bounded in count and length.
|
||||
/// <paramref name="oobeOnly"/> keeps only names that mention OOBE.
|
||||
/// </summary>
|
||||
internal static string FormatEntries(IEnumerable<KeyValuePair<string, object>> entries, bool oobeOnly)
|
||||
{
|
||||
var selected = entries
|
||||
.Where(e => !oobeOnly || IsOobeName(e.Key))
|
||||
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (selected.Count == 0) return oobeOnly ? "(no OOBE values)" : "(no values)";
|
||||
|
||||
var shown = selected.Take(MaxEntries)
|
||||
.Select(e => $"{(e.Key.Length == 0 ? "(default)" : e.Key)}={FormatValue(e.Value)}");
|
||||
var line = string.Join(", ", shown);
|
||||
return selected.Count > MaxEntries ? $"{line}, … (+{selected.Count - MaxEntries} more)" : line;
|
||||
}
|
||||
|
||||
internal static string FormatValue(object value)
|
||||
{
|
||||
string text;
|
||||
switch (value)
|
||||
{
|
||||
case null: text = "(null)"; break;
|
||||
case int i: text = i.ToString(CultureInfo.InvariantCulture); break;
|
||||
case long l: text = l.ToString(CultureInfo.InvariantCulture); break;
|
||||
case string s: text = s; break;
|
||||
case string[] multi: text = string.Join("|", multi); break;
|
||||
case byte[] bytes: text = $"(binary {bytes.Length} bytes)"; break;
|
||||
default: text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; break;
|
||||
}
|
||||
return text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "…" : text;
|
||||
}
|
||||
|
||||
private static bool IsOobeName(string name) =>
|
||||
name != null && name.IndexOf("OOBE", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// One CloudExperienceHost record of the OOBE update page, parsed by
|
||||
/// <see cref="ShellCoreTracker.ParseUpdatePageRecord"/>.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdatePageRecord
|
||||
{
|
||||
public const string PageStarted = "page_started";
|
||||
public const string PageStopped = "page_stopped";
|
||||
public const string EventName = "event_name";
|
||||
|
||||
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
|
||||
{
|
||||
CxhEvent = cxhEvent;
|
||||
WindowsEventId = windowsEventId;
|
||||
Page = page;
|
||||
Result = result;
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary><see cref="PageStarted"/>, <see cref="PageStopped"/> or <see cref="EventName"/>.</summary>
|
||||
public string CxhEvent { get; }
|
||||
public int WindowsEventId { get; }
|
||||
|
||||
/// <summary>The page's CXID (start), or the page a stop is attributed to.</summary>
|
||||
public string Page { get; }
|
||||
|
||||
/// <summary>The page result of a stop.</summary>
|
||||
public string Result { get; }
|
||||
|
||||
/// <summary>The event name of a 62406/62407 record.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>A 62407 value of a safe form (boolean, integer, HRESULT); null otherwise.</summary>
|
||||
public string Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE quality update (D-310): the update page's
|
||||
/// CloudExperienceHost records as <c>oobe_update_page</c> and the registry state behind it as
|
||||
/// <c>oobe_update_state</c>. Debug, bounded per agent run, no decisions. Owned by
|
||||
/// <see cref="ShellCoreTracker"/>, which observes the page.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdateTelemetry
|
||||
{
|
||||
internal const int MaxPageEventsPerRun = 50;
|
||||
internal const int MaxPageEventsPerKey = 3;
|
||||
internal const int MaxStateEventsPerRun = 6;
|
||||
internal const int MaxStateLineLength = 1000;
|
||||
|
||||
/// <summary>When a registry snapshot is taken (<c>moment</c> of <c>oobe_update_state</c>).</summary>
|
||||
internal static class Moments
|
||||
{
|
||||
public const string AgentStart = "agent_start";
|
||||
public const string UpdatePageStarted = "update_page_started";
|
||||
public const string UpdatePageStopped = "update_page_stopped";
|
||||
public const string AgentStop = "agent_stop";
|
||||
}
|
||||
|
||||
private const string Source = "ShellCoreTracker";
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> _readRegistry;
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _pageEventsPerKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private int _pageEvents;
|
||||
private bool _pageCapLogged;
|
||||
private int _stateEvents;
|
||||
private string _lastStateContent;
|
||||
|
||||
public OobeUpdateTelemetry(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> readRegistry)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_readRegistry = readRegistry ?? throw new ArgumentNullException(nameof(readRegistry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>oobe_update_page</c> per update-page record, at most
|
||||
/// <see cref="MaxPageEventsPerKey"/> per page/name and <see cref="MaxPageEventsPerRun"/>
|
||||
/// per run — a page that logs the same name in a loop cannot flood the session.
|
||||
/// </summary>
|
||||
public void ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
if (record == null) return;
|
||||
|
||||
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
|
||||
int occurrence;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pageEvents >= MaxPageEventsPerRun)
|
||||
{
|
||||
if (!_pageCapLogged)
|
||||
{
|
||||
_pageCapLogged = true;
|
||||
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} events reached, the rest of this run stays in agent.log");
|
||||
}
|
||||
return;
|
||||
}
|
||||
_pageEventsPerKey.TryGetValue(key, out occurrence);
|
||||
if (occurrence >= MaxPageEventsPerKey) return;
|
||||
occurrence++;
|
||||
_pageEventsPerKey[key] = occurrence;
|
||||
_pageEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "cxhEvent", record.CxhEvent },
|
||||
{ "windowsEventId", record.WindowsEventId },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfill", isBackfill },
|
||||
{ "occurrence", occurrence },
|
||||
};
|
||||
if (record.Page != null) data["page"] = record.Page;
|
||||
if (record.Result != null) data["result"] = record.Result;
|
||||
if (record.Name != null) data["name"] = record.Name;
|
||||
if (record.Value != null) data["value"] = record.Value;
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = occurredAtUtc,
|
||||
EventType = Constants.EventTypes.OobeUpdatePage,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = DescribePage(record),
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update page telemetry failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the OOBE update registry state, logs it and reports it as <c>oobe_update_state</c>
|
||||
/// — only when at least one key holds values and the content changed since the last report
|
||||
/// of this run, at most <see cref="MaxStateEventsPerRun"/> times. Fail-soft.
|
||||
/// </summary>
|
||||
public void ReportState(string moment, string page = null)
|
||||
{
|
||||
var label = page == null ? moment : $"{moment} {page}";
|
||||
IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> states;
|
||||
try
|
||||
{
|
||||
states = _readRegistry() ?? Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update registry ({label}): snapshot failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OobeUpdateRegistrySnapshot.Log(_logger, label, states);
|
||||
|
||||
var withValues = states.Where(s => s.HasValues).ToList();
|
||||
if (withValues.Count == 0) return;
|
||||
|
||||
var content = string.Join("\n", withValues.Select(s => $"{s.Alias}={s.Line}"));
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.Equals(content, _lastStateContent, StringComparison.Ordinal)) return;
|
||||
if (_stateEvents >= MaxStateEventsPerRun) return;
|
||||
_lastStateContent = content;
|
||||
_stateEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "moment", moment },
|
||||
{ "keysWithValues", withValues.Count },
|
||||
};
|
||||
if (page != null) data["page"] = page;
|
||||
foreach (var state in withValues)
|
||||
{
|
||||
data[state.Alias] = state.Line.Length > MaxStateLineLength
|
||||
? state.Line.Substring(0, MaxStateLineLength) + "…"
|
||||
: state.Line;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.OobeUpdateState,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"OOBE update registry state ({label}): {withValues.Count} key(s) with values",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update state telemetry failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePage(OobeUpdatePageRecord record)
|
||||
{
|
||||
switch (record.CxhEvent)
|
||||
{
|
||||
case OobeUpdatePageRecord.PageStarted:
|
||||
return $"OOBE update page started: {record.Page}";
|
||||
case OobeUpdatePageRecord.PageStopped:
|
||||
return $"OOBE update page stopped: {record.Page} (result: {record.Result})";
|
||||
default:
|
||||
return record.Value == null
|
||||
? $"OOBE update page event: {record.Name}"
|
||||
: $"OOBE update page event: {record.Name} = {record.Value}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches the CBS servicing steps of OS updates in the <c>Setup</c> log
|
||||
/// (<c>Microsoft-Windows-Servicing</c>, D-310): 1 initiating changes (current → target state),
|
||||
/// 2 target state reached, 3 failed, 4 a restart is needed to finish, 6 failed and partially
|
||||
/// installed. The Windows Update client logs only the start of an installation; these steps
|
||||
/// show the long part of it — staging, installing, "reboot required" right before the restart.
|
||||
/// <para>
|
||||
/// Only packages of the OS update clients (<c>UpdateAgentLCU</c> for the cumulative update,
|
||||
/// <c>WindowsUpdateAgent</c>) are emitted as <c>windows_update_servicing</c> and reported to
|
||||
/// the decision engine; language packs, features on demand and DISM operations started by
|
||||
/// apps are counted into the host's <see cref="UpdateActivityTally"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ServicingTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Setup";
|
||||
internal const string ProviderName = "Microsoft-Windows-Servicing";
|
||||
|
||||
internal const int EventId_InitiatingChanges = 1;
|
||||
internal const int EventId_StateReached = 2;
|
||||
internal const int EventId_ChangeFailed = 3;
|
||||
internal const int EventId_RebootRequired = 4;
|
||||
internal const int EventId_PartiallyInstalled = 6;
|
||||
|
||||
internal static readonly int[] TargetedEventIds =
|
||||
{
|
||||
EventId_InitiatingChanges, EventId_StateReached, EventId_ChangeFailed, EventId_RebootRequired, EventId_PartiallyInstalled,
|
||||
};
|
||||
|
||||
internal const string WatermarkStateFileName = "servicing-watermark.json";
|
||||
|
||||
// CBS client ids of the Windows Update installers (seen on 24H2/25H2: UpdateAgentLCU).
|
||||
private static readonly HashSet<string> OsUpdateClients = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"UpdateAgentLCU",
|
||||
"WindowsUpdateAgent",
|
||||
};
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
public ServicingTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(stateDirectory, WatermarkStateFileName, logger, "Servicing");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
BackfillRecentEvents();
|
||||
else
|
||||
_logger.Info("Servicing backfill disabled by config");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("Servicing watcher stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping Servicing watcher", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
private static HashSet<int> TargetedSet => new HashSet<int>(TargetedEventIds);
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(Channel, PathType.LogName,
|
||||
WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"Servicing watcher started: {Channel} ({ProviderName}, targetedIds={string.Join(",", TargetedEventIds)})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing watcher access denied for {Channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start Servicing watcher for {Channel}", ex);
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "ServicingTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Servicing event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, bool isBackfill)
|
||||
{
|
||||
var recordId = record.RecordId ?? -1;
|
||||
if (_watermark.IsAlreadyProcessed(recordId)) return;
|
||||
|
||||
string xml = null;
|
||||
try { xml = record.ToXml(); }
|
||||
catch { }
|
||||
|
||||
var fields = ParseUserData(xml);
|
||||
fields.TryGetValue("PackageIdentifier", out var package);
|
||||
fields.TryGetValue("InitialPackageStateTextized", out var initialState);
|
||||
fields.TryGetValue("IntendedPackageStateTextized", out var targetState);
|
||||
fields.TryGetValue("ErrorCode", out var errorCode);
|
||||
fields.TryGetValue("Client", out var client);
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>Test seam — drives the processing without a Windows-only <see cref="EventRecord"/>.</summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
string package,
|
||||
string initialState,
|
||||
string targetState,
|
||||
string errorCode,
|
||||
string client,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (!_watermark.TryClaim(recordId)) return;
|
||||
|
||||
if (string.IsNullOrEmpty(client) || !OsUpdateClients.Contains(client))
|
||||
{
|
||||
_tally?.Increment($"servicing_other_{eventId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var pkg = string.IsNullOrEmpty(package) ? "(unknown package)" : package;
|
||||
var target = string.IsNullOrEmpty(targetState) ? "?" : targetState;
|
||||
|
||||
string step;
|
||||
string osStep;
|
||||
string message;
|
||||
var severity = EventSeverity.Info;
|
||||
string hresultHex = null;
|
||||
string hresultSymbol = null;
|
||||
if (WindowsUpdateTracker.TryNormalizeHResult(errorCode, out var hresultValue, out var hex) && hresultValue != 0)
|
||||
{
|
||||
hresultHex = hex;
|
||||
hresultSymbol = WindowsUpdateTracker.ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InitiatingChanges:
|
||||
step = "initiating";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.InstallStarted
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.StagingStarted
|
||||
: null;
|
||||
message = $"Servicing started for {pkg}: {(string.IsNullOrEmpty(initialState) ? "?" : initialState)} → {target}";
|
||||
break;
|
||||
case EventId_StateReached:
|
||||
step = "state_reached";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.Installed
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.Staged
|
||||
: null;
|
||||
message = $"Servicing finished for {pkg}: now {target}";
|
||||
break;
|
||||
case EventId_RebootRequired:
|
||||
step = "reboot_required";
|
||||
osStep = OsUpdateSteps.RebootRequired;
|
||||
message = $"Servicing needs a restart to finish {pkg} → {target}";
|
||||
break;
|
||||
case EventId_ChangeFailed:
|
||||
case EventId_PartiallyInstalled:
|
||||
step = "failed";
|
||||
osStep = OsUpdateSteps.Failed;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Servicing FAILED for {pkg} → {target}" +
|
||||
(hresultHex != null ? $" ({hresultHex} {hresultSymbol})" : string.Empty) +
|
||||
(eventId == EventId_PartiallyInstalled ? ", package is partially installed" : string.Empty);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "servicingEventId", eventId },
|
||||
{ "step", step },
|
||||
{ "package", pkg },
|
||||
{ "client", client },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(initialState)) data["initialState"] = initialState;
|
||||
if (!string.IsNullOrEmpty(targetState)) data["targetState"] = targetState;
|
||||
if (hresultHex != null)
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = hresultSymbol;
|
||||
}
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.WindowsUpdateServicing,
|
||||
Severity = severity,
|
||||
Source = "ServicingWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
|
||||
if (osStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(osStep, pkg, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"Servicing: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static bool IsState(string state, string expected) =>
|
||||
string.Equals(state, expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Child elements of the first element under <c>UserData</c>
|
||||
/// (<c>CbsPackageInitiateChanges</c> / <c>CbsPackageChangeState</c>), keyed by local name,
|
||||
/// case-insensitively. Empty on null/malformed XML — never throws.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseUserData(string xml)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(xml)) return result;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var userData = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "UserData");
|
||||
var payload = userData?.Elements().FirstOrDefault();
|
||||
if (payload == null) return result;
|
||||
|
||||
foreach (var el in payload.Elements())
|
||||
{
|
||||
if (!result.ContainsKey(el.Name.LocalName))
|
||||
result[el.Name.LocalName] = el.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unexpected XML — best effort.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void BackfillRecentEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var xpath = WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName, lookbackMs);
|
||||
_logger.Info($"Servicing backfill: scanning {Channel} (lookback={_backfillLookbackMinutes}min, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(new EventLogQuery(Channel, PathType.LogName, xpath)))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
ProcessRecord(record, isBackfill: true);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processed > 0)
|
||||
_logger.Info($"Servicing backfill: scanned {processed} event(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found during backfill: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
-3
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -31,9 +32,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
|
||||
internal const int EventId_ShellCore_WebAppStarted = 62404;
|
||||
internal const int EventId_ShellCore_WebAppStopped = 62405;
|
||||
internal const int EventId_ShellCore_WebAppEventName = 62406;
|
||||
internal const int EventId_ShellCore_WebAppEvent = 62407;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
// 62405 (page stopped) and 62406 (event with a name only) feed the CloudExperienceHost
|
||||
// navigation breadcrumbs in agent.log and the OOBE update page telemetry (D-310), nothing else.
|
||||
private const string WatchedEventIdsXPath =
|
||||
"(EventID=62404 or EventID=62405 or EventID=62406 or EventID=62407)";
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the caller-supplied backfill lookback. Matches the agent's max
|
||||
/// lifetime (360 min) — a window wider than the agent can ever live would only widen
|
||||
@@ -45,22 +53,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
EventId_ShellCore_WebAppStopped,
|
||||
EventId_ShellCore_WebAppEventName,
|
||||
EventId_ShellCore_WebAppEvent
|
||||
};
|
||||
|
||||
private static readonly Regex EspExitingPattern = new Regex(
|
||||
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Manifest templates: 62404 "... started. CXID: '%1'.", 62405 "... stopped. Result: '%1'.",
|
||||
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". A 62407
|
||||
// value can carry URLs with tenant ids — a value leaves the tracker only in a form that
|
||||
// cannot (SafeValuePattern: a boolean, an integer, an HRESULT).
|
||||
private static readonly Regex CxidPattern = new Regex(
|
||||
@"CXID:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ResultPattern = new Regex(
|
||||
@"Result:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex NamePattern = new Regex(
|
||||
@"Name:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ValuePattern = new Regex(
|
||||
@"Value:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex SafeValuePattern = new Regex(
|
||||
@"^(?:true|false|-?[0-9]{1,10}|0x[0-9a-f]{1,8})$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private const int MaxBreadcrumbTokenLength = 128;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly HelloTracker _helloTracker;
|
||||
private readonly OobeUpdateTelemetry _oobeUpdate;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private int _started;
|
||||
private int _stopReported;
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private bool _helloWizardStartDetected;
|
||||
private string _lastCxhPage; // 62405 names no page, it ends the last one started
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
@@ -99,13 +130,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
HelloTracker helloTracker)
|
||||
HelloTracker helloTracker,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
|
||||
// Test seam: a fake registry reader; production reads the real registry.
|
||||
_oobeUpdate = new OobeUpdateTelemetry(sessionId, tenantId, post, logger,
|
||||
oobeUpdateRegistryReader ?? OobeUpdateRegistrySnapshot.Read);
|
||||
}
|
||||
|
||||
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
|
||||
@@ -113,12 +148,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// The OOBE update registry state at every agent start — so also right after the
|
||||
// update's restart (D-310).
|
||||
if (Interlocked.Exchange(ref _started, 1) == 0)
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=62404 or EventID=62407)]]");
|
||||
$"*[System[{WatchedEventIdsXPath}]]");
|
||||
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
@@ -143,6 +183,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Stop runs again from Dispose — one snapshot, and only for a tracker that started.
|
||||
if (Volatile.Read(ref _started) == 1 && Interlocked.Exchange(ref _stopReported, 1) == 0)
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
@@ -186,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
|
||||
{
|
||||
ObserveCxhRecord(eventId, description, timestamp, isBackfill);
|
||||
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity = EventSeverity.Info;
|
||||
string message;
|
||||
@@ -388,7 +435,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=62404 or EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -466,6 +513,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
foreach (var record in records)
|
||||
{
|
||||
var description = record.Description ?? string.Empty;
|
||||
ObserveCxhRecord(record.Id, description, record.OccurredAtUtc, isBackfill: true);
|
||||
|
||||
if (record.Id == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
@@ -473,6 +521,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
continue;
|
||||
}
|
||||
|
||||
// 62405/62406 only feed the breadcrumbs and the update page telemetry above.
|
||||
if (record.Id != EventId_ShellCore_WebAppEvent) continue;
|
||||
|
||||
var isFailure = HasEspFailurePattern(description);
|
||||
var isExit = !isFailure && EspExitingPattern.IsMatch(description);
|
||||
if (!isFailure && !isExit) continue;
|
||||
@@ -569,6 +620,159 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CloudExperienceHost navigation: breadcrumbs and OOBE update page telemetry (D-310)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
|
||||
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
|
||||
/// page itself (<c>OobeNDUP</c>, <c>RebootNDUP</c> and its <c>ExpeditedUpdate_*</c> names)
|
||||
/// are also reported as <c>oobe_update_page</c>, and at the page's live start and stop the
|
||||
/// registry state behind it as <c>oobe_update_state</c>. No decision.
|
||||
/// </summary>
|
||||
private void ObserveCxhRecord(int eventId, string description, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
try
|
||||
{
|
||||
string page;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (eventId == EventId_ShellCore_WebAppStarted)
|
||||
_lastCxhPage = ExtractToken(CxidPattern, description) ?? _lastCxhPage;
|
||||
page = _lastCxhPage;
|
||||
}
|
||||
|
||||
var crumb = FormatCxhBreadcrumb(eventId, description, page);
|
||||
if (crumb == null) return;
|
||||
_logger.Info(isBackfill ? $"{crumb} (backfill, at {occurredAtUtc:o})" : crumb);
|
||||
|
||||
var record = ParseUpdatePageRecord(eventId, description, page);
|
||||
if (record == null) return;
|
||||
_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill);
|
||||
|
||||
// The registry is read now — only a live page boundary is the page's moment.
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
|
||||
{
|
||||
_oobeUpdate.ReportState(
|
||||
record.CxhEvent == OobeUpdatePageRecord.PageStarted
|
||||
? OobeUpdateTelemetry.Moments.UpdatePageStarted
|
||||
: OobeUpdateTelemetry.Moments.UpdatePageStopped,
|
||||
record.Page);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ShellCoreTracker: CXH record observation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The breadcrumb for one Shell-Core record, or null when it is not one: a page start
|
||||
/// (62404, its CXID), a page stop (62405, its result, attributed to the last started
|
||||
/// page), or an update-page event name (62406/62407 — a 62407 value only in a safe form).
|
||||
/// </summary>
|
||||
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
{
|
||||
var cxid = ExtractToken(CxidPattern, description);
|
||||
return cxid == null ? null : $"CXH page started: {cxid}";
|
||||
}
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
{
|
||||
var result = ExtractToken(ResultPattern, description) ?? "?";
|
||||
return $"CXH page stopped: {lastPage ?? "?"} (result={result})";
|
||||
}
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
var name = ExtractToken(NamePattern, description);
|
||||
if (!IsUpdatePageToken(name)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
|
||||
return value == null ? $"CXH event: {name}" : $"CXH event: {name} (value={value})";
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The record of the OOBE update page, or null for any other CloudExperienceHost record:
|
||||
/// the start of a page whose CXID names NDUP, the stop of such a page (62405, attributed
|
||||
/// to the last started page), or an update-page event name from 62406/62407.
|
||||
/// </summary>
|
||||
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
{
|
||||
var cxid = ExtractToken(CxidPattern, description);
|
||||
return IsUpdatePageToken(cxid)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: cxid)
|
||||
: null;
|
||||
}
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return IsUpdatePageToken(lastPage)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
|
||||
page: lastPage, result: ExtractToken(ResultPattern, description) ?? "?")
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
var name = ExtractToken(NamePattern, description);
|
||||
if (!IsUpdatePageToken(name)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
|
||||
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: name, value: value);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsUpdatePageToken(string token) =>
|
||||
token != null
|
||||
&& (token.IndexOf("NDUP", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| token.IndexOf("ExpeditedUpdate", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
/// <summary>
|
||||
/// The 62407 value when it is a boolean, an integer of up to ten digits or an HRESULT —
|
||||
/// forms that cannot carry a URL or a tenant id (the CSP gate, error codes). Null for
|
||||
/// anything else.
|
||||
/// </summary>
|
||||
internal static string ExtractSafeValue(string description)
|
||||
{
|
||||
var value = ExtractToken(ValuePattern, description);
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
try
|
||||
{
|
||||
return SafeValuePattern.IsMatch(value) ? value : null;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractToken(Regex pattern, string description)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description)) return null;
|
||||
try
|
||||
{
|
||||
var match = pattern.Match(description);
|
||||
if (!match.Success) return null;
|
||||
var token = match.Groups[1].Value;
|
||||
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pattern helpers
|
||||
// =====================================================================
|
||||
|
||||
+79
-4
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
|
||||
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
|
||||
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning
|
||||
/// (held back while a tenant-extended Hello wait is pending, emitted once it is over).
|
||||
/// </summary>
|
||||
public class StallProbeCollector
|
||||
{
|
||||
@@ -54,10 +55,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
// True while a tenant-extended Hello wait (up to 60 min) is pending in the decision
|
||||
// engine. It is a bounded wait that resolves the session either way — not a stall — so
|
||||
// session_stalled is held back until it is over.
|
||||
private readonly Func<bool> _helloWaitPending;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
private bool _sessionStalledFired;
|
||||
|
||||
// The session_stalled report held back while the Hello wait was pending (the probe result
|
||||
// of that idle window). Emitted on a later tick once the wait is over and the session is
|
||||
// still idle; dropped by real activity (ResetProbes).
|
||||
private ProbeResult _deferredSessionStalled;
|
||||
|
||||
// P2 — monotonic generation, bumped by ResetProbes. RunProbe runs OUTSIDE _stateLock; if a
|
||||
// real-activity reset lands while a probe is in flight, the post-probe "mark fired" must be
|
||||
// dropped (its generation is stale) so the next idle period can fire that probe again.
|
||||
@@ -153,9 +164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool> helloWaitPending = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_helloWaitPending = helloWaitPending;
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -192,6 +205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Always bump the generation, even when no probes are currently marked fired — a
|
||||
// probe may be running right now (outside the lock) and must not re-mark itself.
|
||||
_probeGeneration++;
|
||||
_deferredSessionStalled = null;
|
||||
if (_firedProbeIndices.Count > 0)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
|
||||
@@ -206,6 +220,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public void CheckAndRunProbes(double idleMinutes)
|
||||
{
|
||||
EmitDeferredSessionStalledIfDue(idleMinutes);
|
||||
|
||||
// Iterate thresholds from lowest to highest so probes fire in order.
|
||||
for (int i = 0; i < _thresholdsMinutes.Length; i++)
|
||||
{
|
||||
@@ -225,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (alreadyFired)
|
||||
continue;
|
||||
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes, genAtStart);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
@@ -237,7 +253,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes, int generation)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
|
||||
@@ -293,8 +309,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// session_stalled is fire-once across the whole session. It fires at the configured
|
||||
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
|
||||
// While a tenant Hello wait is pending it is held back, not dropped.
|
||||
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
|
||||
{
|
||||
if (IsHelloWaitPending())
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
// P2: a reset that landed while this probe ran ended the idle window.
|
||||
if (!_sessionStalledFired && _probeGeneration == generation)
|
||||
_deferredSessionStalled = result;
|
||||
}
|
||||
_logger.Info($"StallProbeCollector: session_stalled held back at probe {probeIndex} — a tenant Hello wait is still pending");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_sessionStalledFired)
|
||||
@@ -305,6 +334,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the held-back session_stalled report once the Hello wait is over and the session
|
||||
/// is still idle (no ResetProbes since), with the idle time of this tick.
|
||||
/// </summary>
|
||||
private void EmitDeferredSessionStalledIfDue(double idleMinutes)
|
||||
{
|
||||
ProbeResult deferred;
|
||||
lock (_stateLock)
|
||||
{
|
||||
deferred = _deferredSessionStalled;
|
||||
if (deferred == null || _sessionStalledFired)
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsHelloWaitPending())
|
||||
return;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_sessionStalledFired || !ReferenceEquals(_deferredSessionStalled, deferred))
|
||||
return;
|
||||
_sessionStalledFired = true;
|
||||
_deferredSessionStalled = null;
|
||||
}
|
||||
|
||||
deferred.IdleMinutes = Math.Round(idleMinutes, 1);
|
||||
_logger.Info($"StallProbeCollector: Hello wait over, still idle at {deferred.IdleMinutes}min — emitting the held-back session_stalled");
|
||||
EmitSessionStalledEvent(deferred);
|
||||
}
|
||||
|
||||
private bool IsHelloWaitPending()
|
||||
{
|
||||
if (_helloWaitPending == null)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
return _helloWaitPending();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failing probe must never cost the stall report — treat as not pending.
|
||||
_logger.Warning($"StallProbeCollector: Hello-wait probe threw, treating as not pending: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunWithTimeout(Action<ProbeResult> scan, int timeoutMs, string sourceName, ProbeResult result)
|
||||
{
|
||||
// Enforce a real hard timeout: run the source on a dedicated background thread and
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe counters for update activity that is counted instead of emitted (D-310): Store,
|
||||
/// Defender and other non-OS update events, repeated scan results, servicing steps of non-OS
|
||||
/// packages. Shared by the update watchers of one host, which reports the totals once as
|
||||
/// <c>windows_update_activity_summary</c> when it stops.
|
||||
/// </summary>
|
||||
internal sealed class UpdateActivityTally
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
public void Increment(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_counts.TryGetValue(key, out var current);
|
||||
_counts[key] = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEmpty
|
||||
{
|
||||
get { lock (_lock) return _counts.Count == 0; }
|
||||
}
|
||||
|
||||
/// <summary>Counts ordered by key, for a stable event payload.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, int>> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _counts.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts a Windows Update client event into the update class that decides how it is reported
|
||||
/// (D-310). Only <see cref="Os"/> updates — Windows quality/cumulative/servicing-stack/feature
|
||||
/// updates, .NET Framework and the Malicious Software Removal Tool — are enrollment-relevant and
|
||||
/// emitted one by one. Defender intelligence and Store app updates arrive every few minutes on a
|
||||
/// fresh device (up to 171 downloads per hour in the field census) and are only counted.
|
||||
/// </summary>
|
||||
internal static class WindowsUpdateClassifier
|
||||
{
|
||||
public const string Os = "os";
|
||||
public const string Defender = "defender";
|
||||
public const string Store = "store";
|
||||
public const string Other = "other";
|
||||
|
||||
/// <summary>The Microsoft Store service; WU events carry it as <c>serviceGuid</c>.</summary>
|
||||
internal const string StoreServiceGuid = "855e8a7c-ecb4-4ca3-b045-1dfa50104289";
|
||||
|
||||
// Store packages are titled "<12-char product id>-<Publisher.App>", e.g. "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller";
|
||||
// bundles add an "ApplicationSet-" prefix.
|
||||
private static readonly Regex StoreProductTitle =
|
||||
new Regex(@"^(ApplicationSet-)?[0-9A-Z]{12}-\S", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex DefenderTitle =
|
||||
new Regex(@"Microsoft Defender|Windows Defender", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// "Windows" plus "Update"/"Upgrade" covers cumulative, quality, security, servicing-stack,
|
||||
// dynamic and feature updates in both the GA and the Insider title forms.
|
||||
private static readonly Regex WindowsUpdateTitle =
|
||||
new Regex(@"\bWindows\b.*\b(Update|Upgrade)\b|\b(Update|Upgrade)\b.*\bWindows\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex OsTitleExtras =
|
||||
new Regex(@"\.NET Framework|Servicing Stack|Malicious Software Removal Tool",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
public static string Classify(string updateTitle, string serviceGuid)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(serviceGuid)
|
||||
&& serviceGuid.Trim('{', '}', ' ').Equals(StoreServiceGuid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Store;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updateTitle)) return Other;
|
||||
|
||||
try
|
||||
{
|
||||
if (StoreProductTitle.IsMatch(updateTitle)) return Store;
|
||||
if (DefenderTitle.IsMatch(updateTitle)) return Defender;
|
||||
if (OsTitleExtras.IsMatch(updateTitle) || WindowsUpdateTitle.IsMatch(updateTitle)) return Os;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// A pathological title must never cost the event — it is reported as "other".
|
||||
}
|
||||
|
||||
return Other;
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-250
@@ -2,100 +2,106 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative
|
||||
/// update install activity that happens DURING Autopilot OOBE / ESP and forwards it as
|
||||
/// structured <c>windows_update_succeeded</c> / <c>windows_update_failed</c> /
|
||||
/// <c>windows_update_started</c> events.
|
||||
/// Watches ONE channel of the <c>Microsoft-Windows-WindowsUpdateClient</c> provider for update
|
||||
/// activity during OOBE / ESP and forwards it as <c>windows_update_started</c> /
|
||||
/// <c>windows_update_succeeded</c> / <c>windows_update_failed</c> events.
|
||||
/// <para>
|
||||
/// This is a blind spot no other tool surfaces well: a cumulative update installing mid-OOBE
|
||||
/// can break the enrollment (r/Intune KB5095189), and the ESP "Install Windows quality updates
|
||||
/// during OOBE" feature makes it increasingly common. The Intune console shows none of it — it
|
||||
/// is otherwise deep manual digging on the device.
|
||||
/// <b>Two channels (D-310).</b> The provider manifest writes the install events — 19 success,
|
||||
/// 20 failure (HRESULT), 43 install started, 44 download started — to the <b>System</b> log and
|
||||
/// the scan/download events — 25 scan failed, 26 scan found N, 31 download failed, 41
|
||||
/// downloaded — to the Operational channel. The host runs one instance per channel; RecordIds
|
||||
/// are per channel, so each instance keeps its own watermark. The System log is shared with
|
||||
/// every other provider, hence the provider filter in its XPath.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill.</b> OOBE updates run at the early "Getting updates" screen, frequently BEFORE the
|
||||
/// agent starts. The WindowsUpdateClient/Operational .evtx persists across the mid-OOBE reboot,
|
||||
/// so a startup backfill with a generous lookback catches those pre-agent events.
|
||||
/// <b>Classification.</b> Only Windows/.NET updates (<see cref="WindowsUpdateClassifier.Os"/>)
|
||||
/// are emitted one by one and reported to the decision engine. Store, Defender and other
|
||||
/// updates arrive every few minutes on a fresh device and are only counted into the host's
|
||||
/// <see cref="UpdateActivityTally"/>. Repeated scan results (26) and scan failures (25) are
|
||||
/// deduplicated; their repeats are counted the same way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-restart dedup.</b> Unlike the single-latch WhiteGlove backfill, WU events are many
|
||||
/// distinct records, so an agent restart would re-read (and re-emit) events already reported in
|
||||
/// the prior run. We persist the highest processed <see cref="EventRecord.RecordId"/> as a
|
||||
/// watermark and skip records at/below it. Live records always carry a higher RecordId and pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verified EventIDs (build against these):</b> 19=install success, 20=install failure
|
||||
/// (carries HRESULT in <c>errorCode</c>), 43=install started, 44=download started. IDs whose
|
||||
/// semantics are unverified across sources (25/26/31, restart-required 21 vs 41) are excluded by
|
||||
/// default and can be added via <c>CollectorConfiguration.WindowsUpdateTargetedEventIds</c>
|
||||
/// after validation on a real device trace — no redeploy needed.
|
||||
/// <b>Backfill.</b> Updates can start before the agent does; the .evtx survives the reboot, so
|
||||
/// a startup backfill with a generous lookback catches them. Backfilled events keep their own
|
||||
/// event time on the timeline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Microsoft-Windows-WindowsUpdateClient/Operational";
|
||||
internal const string SystemChannel = "System";
|
||||
internal const string ProviderName = "Microsoft-Windows-WindowsUpdateClient";
|
||||
internal const string OrchestratorChannel = "Microsoft-Windows-UpdateOrchestrator/Operational";
|
||||
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
// System channel (provider manifest).
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
internal const int EventId_InstallStarted = 43;
|
||||
internal const int EventId_DownloadStarted = 44;
|
||||
// Operational channel.
|
||||
internal const int EventId_ScanFailed = 25;
|
||||
internal const int EventId_ScanFound = 26;
|
||||
internal const int EventId_DownloadFailed = 31;
|
||||
internal const int EventId_Downloaded = 41;
|
||||
|
||||
internal const string WatermarkStateFileName = "windows-update-watermark.json";
|
||||
internal const string SystemWatermarkStateFileName = "windows-update-system-watermark.json";
|
||||
|
||||
// Hard bound for the unfiltered census scan — during OOBE both channels carry at most a
|
||||
// few hundred records in the lookback window; the cap only guards against a pathological
|
||||
// log. A truncated census says so in its payload (no silent caps).
|
||||
internal const int CensusRecordCap = 5000;
|
||||
|
||||
// Scan results repeat every few minutes; only changes are worth a timeline row.
|
||||
internal const int MaxScanFoundEmissions = 10;
|
||||
internal const int MaxScanFailedEmissions = 5;
|
||||
|
||||
private static readonly int[] DefaultSystemEventIds = { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
private static readonly int[] DefaultOperationalEventIds = { EventId_ScanFailed, EventId_ScanFound, EventId_DownloadFailed, EventId_Downloaded };
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly string _channel;
|
||||
private readonly bool _isSystemChannel;
|
||||
private readonly HashSet<int> _targetedEventIds;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly bool _channelCensusEnabled;
|
||||
private readonly Func<bool> _osBuildChangedProvider;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
|
||||
// Count of targeted WU events emitted THIS run (live + backfill). Zero after the backfill
|
||||
// while the OS build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
// Targeted events processed THIS run (any class). Zero after the backfill while the OS
|
||||
// build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
private int _emittedThisRun;
|
||||
|
||||
private readonly object _throttleLock = new object();
|
||||
private readonly HashSet<string> _scanFailureCodesReported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastScanFoundCount;
|
||||
private int _scanFoundEmitted;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
|
||||
//
|
||||
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
|
||||
// Immutable during this run — events at/below it were already emitted before this process
|
||||
// started and must not be re-emitted by the backfill scan.
|
||||
private long _restartWatermark = -1;
|
||||
// RecordIds emitted in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
|
||||
// high-water mark: the live watcher is armed before the backfill runs, so a live event with a
|
||||
// HIGHER RecordId can be processed before/during backfill. A high-water mark would then make
|
||||
// the dedup skip every older, never-emitted backfill event (recordId <= max) — silently
|
||||
// dropping exactly the early pre-agent OOBE updates that are this feature's whole point.
|
||||
// WU install events are low-volume (a handful per enrollment), so the set stays tiny.
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
// Highest RecordId emitted so far (max of restart watermark + this run). Persisted so the next
|
||||
// run's restart watermark is correct.
|
||||
private long _maxEmittedRecordId = -1;
|
||||
private readonly object _watermarkLock = new object();
|
||||
|
||||
/// <param name="channel">Event log channel; defaults to the Operational channel.</param>
|
||||
/// <param name="targetedEventIds">IDs to watch on <paramref name="channel"/>; defaults to the manifest set of that channel.</param>
|
||||
/// <param name="tally">Counter for activity that is counted instead of emitted; null drops the counts.</param>
|
||||
/// <param name="onOsUpdateActivity">Called for every Windows/.NET update step with (step, update title, event time).</param>
|
||||
public WindowsUpdateTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -106,25 +112,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool> osBuildChangedProvider = null)
|
||||
Func<bool> osBuildChangedProvider = null,
|
||||
string channel = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_channel = string.IsNullOrEmpty(channel) ? Channel : channel;
|
||||
_isSystemChannel = string.Equals(_channel, SystemChannel, StringComparison.OrdinalIgnoreCase);
|
||||
_targetedEventIds = targetedEventIds != null && targetedEventIds.Length > 0
|
||||
? new HashSet<int>(targetedEventIds)
|
||||
: new HashSet<int> { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
: new HashSet<int>(_isSystemChannel ? DefaultSystemEventIds : DefaultOperationalEventIds);
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_channelCensusEnabled = channelCensusEnabled;
|
||||
_osBuildChangedProvider = osBuildChangedProvider;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(
|
||||
stateDirectory,
|
||||
_isSystemChannel ? SystemWatermarkStateFileName : WatermarkStateFileName,
|
||||
logger,
|
||||
_isSystemChannel ? "WindowsUpdate (System)" : "WindowsUpdate");
|
||||
}
|
||||
|
||||
/// <summary>Channel this instance watches.</summary>
|
||||
internal string WatchedChannel => _channel;
|
||||
|
||||
/// <summary>Targeted events processed this run, any class.</summary>
|
||||
internal int EmittedThisRun => Volatile.Read(ref _emittedThisRun);
|
||||
|
||||
/// <summary>EventIDs this instance watches on <see cref="WatchedChannel"/>.</summary>
|
||||
internal IReadOnlyCollection<int> TargetedEventIds => _targetedEventIds;
|
||||
|
||||
/// <summary>
|
||||
/// True when the provider manifest writes <paramref name="eventId"/> to the System log
|
||||
/// (16–24, 27, 28, 32, 33, 43, 44, 212–218); all other IDs live in the Operational channel.
|
||||
/// </summary>
|
||||
internal static bool IsSystemChannelEventId(int eventId) =>
|
||||
(eventId >= 16 && eventId <= 24)
|
||||
|| eventId == 27 || eventId == 28
|
||||
|| eventId == 32 || eventId == 33
|
||||
|| eventId == 43 || eventId == 44
|
||||
|| (eventId >= 212 && eventId <= 218);
|
||||
|
||||
/// <summary>
|
||||
/// Watches the channel and backfills it. The blind-spot census is NOT run here: it needs
|
||||
/// the emissions of every channel, so the host runs it after all its trackers started.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
LoadWatermark();
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
@@ -133,10 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("WindowsUpdate backfill disabled by config");
|
||||
_logger.Info($"WindowsUpdate backfill disabled by config ({_channel})");
|
||||
}
|
||||
|
||||
EmitChannelCensusIfBlind();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
@@ -146,11 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("WindowsUpdate watcher stopped");
|
||||
_logger.Info($"WindowsUpdate watcher stopped ({_channel})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping WindowsUpdate watcher", ex);
|
||||
_logger.Error($"Error stopping WindowsUpdate watcher ({_channel})", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -160,52 +199,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private string ProviderFilter => _isSystemChannel ? ProviderName : null;
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
if (_targetedEventIds.Count == 0)
|
||||
{
|
||||
_logger.Warning("WindowsUpdate watcher not started: no targeted EventIDs configured");
|
||||
_logger.Warning($"WindowsUpdate watcher not started: no targeted EventIDs configured ({_channel})");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var xpath = BuildXPath(_targetedEventIds);
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, BuildXPath(_targetedEventIds, ProviderFilter));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"WindowsUpdate watcher started: {Channel} (targetedIds={_targetedEventIds.Count})");
|
||||
_logger.Info($"WindowsUpdate watcher started: {_channel} (targetedIds={string.Join(",", _targetedEventIds.OrderBy(id => id))})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {Channel}: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {_channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {Channel}", ex);
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {_channel}", ex);
|
||||
// MON-D1: surface a dead watcher as one-shot telemetry, not just a local log line.
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{_channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Targeted-EventID XPath filter without a provider clause. Exposed for tests.</summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds) =>
|
||||
BuildXPath(targetedEventIds, providerName: null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the targeted-EventID XPath filter. Exposed as internal for tests.
|
||||
/// Targeted-EventID XPath filter, optionally restricted to <paramref name="providerName"/>
|
||||
/// (mandatory on the shared System log) and to the last <paramref name="lookbackMs"/>.
|
||||
/// </summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds)
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds, string providerName, long? lookbackMs = null)
|
||||
{
|
||||
var idClauses = string.Join(" or ",
|
||||
targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
return $"*[System[({idClauses})]]";
|
||||
var clauses = new List<string>(3);
|
||||
if (!string.IsNullOrEmpty(providerName))
|
||||
clauses.Add($"Provider[@Name='{providerName}']");
|
||||
clauses.Add("(" + string.Join(" or ", targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}")) + ")");
|
||||
if (lookbackMs.HasValue)
|
||||
clauses.Add($"TimeCreated[timediff(@SystemTime) <= {lookbackMs.Value}]");
|
||||
return $"*[System[{string.Join(" and ", clauses)}]]";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -230,7 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var recordId = record.RecordId ?? -1;
|
||||
|
||||
// Cheap dedup gate BEFORE the expensive XML/FormatDescription work.
|
||||
if (IsAlreadyProcessed(recordId))
|
||||
if (_watermark.IsAlreadyProcessed(recordId))
|
||||
return;
|
||||
|
||||
string xml = null;
|
||||
@@ -242,6 +293,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
eventData.TryGetValue("updateGuid", out var updateGuid);
|
||||
eventData.TryGetValue("updateRevisionNumber", out var updateRevision);
|
||||
eventData.TryGetValue("errorCode", out var errorCode);
|
||||
eventData.TryGetValue("serviceGuid", out var serviceGuid);
|
||||
eventData.TryGetValue("updateCount", out var updateCount);
|
||||
|
||||
string description = null;
|
||||
try { description = record.FormatDescription(); }
|
||||
@@ -257,7 +310,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
updateRevisionNumber: updateRevision,
|
||||
errorCode: errorCode,
|
||||
formattedDescription: description,
|
||||
isBackfill: isBackfill);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -275,66 +330,68 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string updateRevisionNumber,
|
||||
string errorCode,
|
||||
string formattedDescription,
|
||||
bool isBackfill)
|
||||
bool isBackfill,
|
||||
string serviceGuid = null,
|
||||
string updateCount = null)
|
||||
{
|
||||
if (!MarkProcessed(recordId))
|
||||
if (!_watermark.TryClaim(recordId))
|
||||
return; // already emitted (cross-restart or duplicate delivery)
|
||||
|
||||
System.Threading.Interlocked.Increment(ref _emittedThisRun);
|
||||
Interlocked.Increment(ref _emittedThisRun);
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
bool immediateUpload;
|
||||
ClassifyEventId(eventId, out eventType, out severity, out immediateUpload);
|
||||
var shape = ShapeFor(eventId);
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
|
||||
// Scan-level events name no update: deduplicate instead of classifying. Every other
|
||||
// event is classified; only Windows/.NET updates are listed, the rest is counted —
|
||||
// so windows_update_succeeded/failed always mean an OS update (ANALYZE-DEV-004/005).
|
||||
string updateClass = null;
|
||||
if (eventId == EventId_ScanFound)
|
||||
{
|
||||
if (!ClaimScanFoundEmission(updateCount)) { _tally?.Increment("scan_found_repeat"); return; }
|
||||
}
|
||||
else if (eventId == EventId_ScanFailed)
|
||||
{
|
||||
if (!ClaimScanFailedEmission(errorCode)) { _tally?.Increment("scan_failed_repeat"); return; }
|
||||
}
|
||||
else
|
||||
{
|
||||
updateClass = WindowsUpdateClassifier.Classify(updateTitle, serviceGuid);
|
||||
if (updateClass != WindowsUpdateClassifier.Os)
|
||||
{
|
||||
_tally?.Increment($"{updateClass}_{eventId}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "wuEventId", eventId },
|
||||
{ "wuChannel", _isSystemChannel ? "system" : "operational" },
|
||||
{ "wuPhase", shape.Phase },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (updateClass != null) data["updateClass"] = updateClass;
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (level.HasValue) data["level"] = level.Value;
|
||||
if (!string.IsNullOrEmpty(updateTitle)) data["updateTitle"] = updateTitle;
|
||||
if (!string.IsNullOrEmpty(updateGuid)) data["updateGuid"] = updateGuid;
|
||||
if (!string.IsNullOrEmpty(updateRevisionNumber)) data["updateRevisionNumber"] = updateRevisionNumber;
|
||||
if (!string.IsNullOrEmpty(updateCount)) data["updateCount"] = updateCount;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
// Decode the HRESULT for failure events (and any event that carries a non-empty errorCode).
|
||||
string hresultHex;
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out hresultHex))
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out var hresultHex))
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
string message;
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess:
|
||||
message = $"Windows Update installed during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallFailure:
|
||||
message = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $"Windows Update FAILED during enrollment: {title} ({data["hresult"]} {sym})"
|
||||
: $"Windows Update FAILED during enrollment: {title}";
|
||||
break;
|
||||
case EventId_DownloadStarted:
|
||||
message = $"Windows Update download started during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallStarted:
|
||||
message = $"Windows Update install started during enrollment: {title}";
|
||||
break;
|
||||
default:
|
||||
message = $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
break;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(formattedDescription))
|
||||
{
|
||||
var truncated = formattedDescription.Length > 1000
|
||||
data["description"] = formattedDescription.Length > 1000
|
||||
? formattedDescription.Substring(0, 1000) + "…"
|
||||
: formattedDescription;
|
||||
data["description"] = truncated;
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
@@ -343,46 +400,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TenantId = _tenantId,
|
||||
// Use the WU event's own time, not the default UtcNow the ctor stamps: backfilled
|
||||
// pre-agent OOBE updates must land on the timeline at when the update actually
|
||||
// installed, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// happened, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// which drives the timeline entry's timestamp (EventTimelineEmitter).
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
EventType = shape.EventType,
|
||||
Severity = shape.Severity,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Message = BuildMessage(eventId, title, updateCount, data),
|
||||
Data = data,
|
||||
ImmediateUpload = immediateUpload,
|
||||
ImmediateUpload = shape.Immediate,
|
||||
});
|
||||
|
||||
if (updateClass == WindowsUpdateClassifier.Os && shape.OsStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(shape.OsStep, updateTitle, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdate: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static void ClassifyEventId(int eventId, out string eventType, out EventSeverity severity, out bool immediateUpload)
|
||||
private struct EventShape
|
||||
{
|
||||
public string EventType;
|
||||
public EventSeverity Severity;
|
||||
public bool Immediate;
|
||||
public string Phase;
|
||||
/// <summary>Step reported to the decision engine for OS updates; null = not a step.</summary>
|
||||
public string OsStep;
|
||||
}
|
||||
|
||||
private static EventShape ShapeFor(int eventId)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallFailure:
|
||||
eventType = Constants.EventTypes.WindowsUpdateFailed;
|
||||
severity = EventSeverity.Error;
|
||||
immediateUpload = true; // a mid-OOBE update failure is high-signal — surface it fast
|
||||
break;
|
||||
case EventId_InstallSuccess:
|
||||
eventType = Constants.EventTypes.WindowsUpdateSucceeded;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateSucceeded, Severity = EventSeverity.Info, Phase = "installed", OsStep = OsUpdateSteps.Installed };
|
||||
case EventId_InstallFailure:
|
||||
// A mid-OOBE install failure is high-signal — surface it fast.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Error, Immediate = true, Phase = "install", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_InstallStarted:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "install", OsStep = OsUpdateSteps.InstallStarted };
|
||||
case EventId_DownloadStarted:
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Debug; // timeline context only
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "download", OsStep = OsUpdateSteps.DownloadStarted };
|
||||
case EventId_Downloaded:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "downloaded", OsStep = OsUpdateSteps.Downloaded };
|
||||
case EventId_DownloadFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "download", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_ScanFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "scan" };
|
||||
case EventId_ScanFound:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Debug, Phase = "scan" };
|
||||
default:
|
||||
// A newly-enabled (still-being-validated) EventID: keep it visible (Info, not Debug)
|
||||
// so it can be assessed on real traces.
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
// An EventID enabled via config whose meaning has not been mapped: keep it
|
||||
// visible (Info, not Debug) so it can be assessed on real traces.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "activity" };
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildMessage(int eventId, string title, string updateCount, Dictionary<string, object> data)
|
||||
{
|
||||
var hresultSuffix = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $" ({data["hresult"]} {sym})"
|
||||
: string.Empty;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess: return $"Windows Update installed during enrollment: {title}";
|
||||
case EventId_InstallFailure: return $"Windows Update FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_InstallStarted: return $"Windows Update install started during enrollment: {title}";
|
||||
case EventId_DownloadStarted: return $"Windows Update download started during enrollment: {title}";
|
||||
case EventId_Downloaded: return $"Windows Update downloaded during enrollment: {title}";
|
||||
case EventId_DownloadFailed: return $"Windows Update download FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_ScanFailed: return $"Windows Update scan failed during enrollment{hresultSuffix}";
|
||||
case EventId_ScanFound:
|
||||
return string.IsNullOrEmpty(updateCount)
|
||||
? "Windows Update scan completed during enrollment"
|
||||
: $"Windows Update scan found {updateCount} update(s) during enrollment";
|
||||
default: return $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFoundEmission(string updateCount)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFoundEmitted >= MaxScanFoundEmissions) return false;
|
||||
if (string.Equals(_lastScanFoundCount, updateCount ?? string.Empty, StringComparison.Ordinal)) return false;
|
||||
_lastScanFoundCount = updateCount ?? string.Empty;
|
||||
_scanFoundEmitted++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFailedEmission(string errorCode)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFailureCodesReported.Count >= MaxScanFailedEmissions) return false;
|
||||
return _scanFailureCodesReported.Add(errorCode ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,14 +614,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var idClauses = string.Join(" or ",
|
||||
_targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
var xpath = BuildXPath(_targetedEventIds, ProviderFilter, lookbackMs);
|
||||
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {Channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_restartWatermark})");
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {_channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, xpath);
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -520,21 +636,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes ({_channel})");
|
||||
else
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s)");
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s) ({_channel})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill access denied: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill access denied ({_channel}): {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill failed: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill failed ({_channel}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,17 +660,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// Self-evidencing blind spot diagnosis. Session 7443317c: an OOBE quality update
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet neither
|
||||
/// the live watcher nor two 60-min backfills captured a single targeted event — the
|
||||
/// update logged via a channel/EventID set the watcher is blind to. With diagnostics
|
||||
/// upload tenant-disabled there is no client-log path to find out which one; so when
|
||||
/// the blind spot provably occurred (build changed AND zero targeted events emitted
|
||||
/// this run), emit a one-shot unfiltered EventID histogram of both update channels
|
||||
/// over the lookback window. The histogram arrives via normal event upload and tells
|
||||
/// us which IDs to add to <c>WindowsUpdateTargetedEventIds</c> via remote config —
|
||||
/// no agent redeploy, no diagnostics access needed.
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet the watcher
|
||||
/// captured nothing — it read the install IDs from the Operational channel, while the
|
||||
/// manifest writes them to the System log (D-310). When the blind spot occurs again
|
||||
/// (build changed AND zero targeted events on every channel this run), emit a one-shot
|
||||
/// unfiltered EventID histogram of the Operational and UpdateOrchestrator channels.
|
||||
/// </summary>
|
||||
internal void EmitChannelCensusIfBlind()
|
||||
/// <param name="otherChannelsEmitted">Targeted events the host's other update watchers processed this run.</param>
|
||||
/// <param name="otherTargetedEventIds">EventIDs the host's other update watchers target, listed with this instance's own.</param>
|
||||
internal void EmitChannelCensusIfBlind(int otherChannelsEmitted = 0, IEnumerable<int> otherTargetedEventIds = null)
|
||||
{
|
||||
if (!_channelCensusEnabled) return;
|
||||
|
||||
@@ -562,7 +676,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_osBuildChangedProvider == null || !_osBuildChangedProvider())
|
||||
return;
|
||||
if (System.Threading.Volatile.Read(ref _emittedThisRun) > 0)
|
||||
if (EmittedThisRun + otherChannelsEmitted > 0)
|
||||
return;
|
||||
|
||||
var lookback = _backfillLookbackMinutes > 0 ? _backfillLookbackMinutes : 60;
|
||||
@@ -581,8 +695,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wuClientCensus", wuClientHistogram },
|
||||
{ "updateOrchestratorCensus", orchestratorHistogram },
|
||||
{ "lookbackMinutes", lookback },
|
||||
{ "restartWatermark", _restartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.OrderBy(id => id)) },
|
||||
{ "restartWatermark", _watermark.RestartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.Concat(otherTargetedEventIds ?? Enumerable.Empty<int>()).Distinct().OrderBy(id => id)) },
|
||||
};
|
||||
if (wuClient.Truncated || orchestrator.Truncated)
|
||||
data["censusTruncated"] = true;
|
||||
@@ -666,102 +780,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>Compact "id=count" form ordered by EventID, e.g. "19=2,25=1,43=2".</summary>
|
||||
internal static string FormatHistogram(Dictionary<int, int> histogram) =>
|
||||
string.Join(",", histogram.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
|
||||
/// see it (caller should emit), false if it was already processed — either in a PRIOR run
|
||||
/// (at/below the restart watermark) or already this run (in the seen-set). Only advances +
|
||||
/// persists the max on a genuinely-new, higher RecordId. Records without a RecordId (-1) are
|
||||
/// always emitted, never tracked or persisted.
|
||||
/// </summary>
|
||||
private bool MarkProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
// Prior run, or already emitted this run. Note: a lower RecordId that was never
|
||||
// emitted this run (e.g. a backfill record read after a higher live record) is NOT
|
||||
// in _seenThisRun and is above the restart watermark, so it is correctly emitted.
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return false;
|
||||
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = _maxEmittedRecordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) PersistWatermark(toPersist);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
if (!File.Exists(filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"WindowsUpdate watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistWatermark(long recordId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
|
||||
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
|
||||
var tempPath = filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, filePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -472,7 +472,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Expand BEFORE the guard: the guard must judge the path that is read,
|
||||
// and an unresolved token is not a path it can judge.
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path, userProfilePath);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
|
||||
+58
-18
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -14,7 +15,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
|
||||
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
|
||||
/// detects the real user via explorer.exe ownership (WTS session query, WMI fallback —
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the result for the agent's lifetime.
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the first user it finds for the
|
||||
/// agent's lifetime. "No user yet" is not cached: the agent usually starts before anyone has
|
||||
/// signed in, so detection is retried — at most every <see cref="RetryInterval"/>.
|
||||
///
|
||||
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
|
||||
/// </summary>
|
||||
@@ -23,9 +26,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
|
||||
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
|
||||
|
||||
/// <summary>Minimum time between two detections while no user has been found.</summary>
|
||||
internal static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null until a user is detected
|
||||
private static DateTime _lastFailedDetectionUtc = DateTime.MinValue;
|
||||
|
||||
// Seams for tests; production uses the real detection and clock.
|
||||
private static Func<string> _detector = DetectLoggedOnUserProfile;
|
||||
private static Func<DateTime> _utcNow = () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
|
||||
@@ -34,17 +44,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static string GetLoggedOnUserProfilePath()
|
||||
{
|
||||
if (_resolved)
|
||||
return _userProfilePath;
|
||||
var cached = Volatile.Read(ref _userProfilePath);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_resolved)
|
||||
if (_userProfilePath != null)
|
||||
return _userProfilePath;
|
||||
|
||||
_userProfilePath = DetectLoggedOnUserProfile();
|
||||
_resolved = true;
|
||||
return _userProfilePath;
|
||||
// A clock that stepped backwards (common during OOBE) never delays the retry.
|
||||
var now = _utcNow();
|
||||
var sinceLastFailure = now - _lastFailedDetectionUtc;
|
||||
if (_lastFailedDetectionUtc != DateTime.MinValue
|
||||
&& sinceLastFailure >= TimeSpan.Zero && sinceLastFailure < RetryInterval)
|
||||
return null;
|
||||
|
||||
var detected = _detector();
|
||||
if (detected != null)
|
||||
Volatile.Write(ref _userProfilePath, detected);
|
||||
else
|
||||
_lastFailedDetectionUtc = now;
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +74,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// Environment.ExpandEnvironmentVariables for standard tokens.
|
||||
/// Returns null if the path contains the token but no user is logged on.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath)
|
||||
public static string ExpandCustomTokens(string rawPath) =>
|
||||
ExpandCustomTokens(rawPath, ContainsUserProfileToken(rawPath) ? GetLoggedOnUserProfilePath() : null);
|
||||
|
||||
/// <summary>
|
||||
/// Same, with the profile path the caller already resolved for its guard. Callers that
|
||||
/// guard the expanded path must use this overload: while detection is still retrying, a
|
||||
/// second lookup can find the user the first one missed, and the guard would then judge a
|
||||
/// C:\Users path without the profile that admits it.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return rawPath;
|
||||
|
||||
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var profilePath = GetLoggedOnUserProfilePath();
|
||||
if (profilePath == null)
|
||||
if (userProfilePath == null)
|
||||
return null; // No user detected — caller should skip this path
|
||||
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, userProfilePath);
|
||||
}
|
||||
|
||||
return Environment.ExpandEnvironmentVariables(rawPath);
|
||||
@@ -80,26 +109,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached state. Only used for testing.
|
||||
/// Resets the cached state and the seams. Only used for testing.
|
||||
/// </summary>
|
||||
internal static void Reset()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_resolved = false;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = DetectLoggedOnUserProfile;
|
||||
_utcNow = () => DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows tests to inject a specific profile path without WMI.
|
||||
/// Allows tests to inject a specific profile path without WMI. Null pins "no user signed in"
|
||||
/// — detection keeps answering null instead of finding the developer's own session.
|
||||
/// </summary>
|
||||
internal static void SetForTesting(string profilePath)
|
||||
{
|
||||
SetDetectorForTesting(() => profilePath, () => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Replaces detection and clock, clearing what was cached. Only used for testing.</summary>
|
||||
internal static void SetDetectorForTesting(Func<string> detector, Func<DateTime> utcNow)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_userProfilePath = profilePath;
|
||||
_resolved = true;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
|
||||
_utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Text encoding of a log file as the logparser reads it. Chosen from the byte order mark at
|
||||
/// the start of the file in <see cref="StreamReader"/>'s detection order; without one the file
|
||||
/// is UTF-8, which is also how ANSI logs were always read (invalid bytes decode to U+FFFD).
|
||||
/// </summary>
|
||||
internal sealed class LogTextEncoding
|
||||
{
|
||||
public static readonly LogTextEncoding Utf8 = new LogTextEncoding("utf-8", new UTF8Encoding(false), 1, 0);
|
||||
public static readonly LogTextEncoding Utf8WithBom = new LogTextEncoding("utf-8 with bom", new UTF8Encoding(false), 1, 3);
|
||||
public static readonly LogTextEncoding Utf16LittleEndian = new LogTextEncoding("utf-16le", new UnicodeEncoding(false, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf16BigEndian = new LogTextEncoding("utf-16be", new UnicodeEncoding(true, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf32LittleEndian = new LogTextEncoding("utf-32le", new UTF32Encoding(false, false), 4, 4);
|
||||
public static readonly LogTextEncoding Utf32BigEndian = new LogTextEncoding("utf-32be", new UTF32Encoding(true, false), 4, 4);
|
||||
|
||||
private LogTextEncoding(string name, Encoding encoding, int unitSize, int byteOrderMarkLength)
|
||||
{
|
||||
Name = name;
|
||||
Encoding = encoding;
|
||||
UnitSize = unitSize;
|
||||
ByteOrderMarkLength = byteOrderMarkLength;
|
||||
LineFeed = encoding.GetBytes("\n");
|
||||
CarriageReturn = encoding.GetBytes("\r");
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public Encoding Encoding { get; }
|
||||
|
||||
/// <summary>Bytes per code unit; every line boundary lies on a unit boundary.</summary>
|
||||
public int UnitSize { get; }
|
||||
|
||||
/// <summary>Length of the byte order mark at offset 0 — skipped, never part of a line.</summary>
|
||||
public int ByteOrderMarkLength { get; }
|
||||
|
||||
public byte[] LineFeed { get; }
|
||||
public byte[] CarriageReturn { get; }
|
||||
|
||||
public static LogTextEncoding Detect(byte[] head)
|
||||
{
|
||||
if (StartsWith(head, 0xEF, 0xBB, 0xBF)) return Utf8WithBom;
|
||||
if (StartsWith(head, 0xFF, 0xFE, 0x00, 0x00)) return Utf32LittleEndian;
|
||||
if (StartsWith(head, 0xFF, 0xFE)) return Utf16LittleEndian;
|
||||
if (StartsWith(head, 0xFE, 0xFF)) return Utf16BigEndian;
|
||||
if (StartsWith(head, 0x00, 0x00, 0xFE, 0xFF)) return Utf32BigEndian;
|
||||
return Utf8;
|
||||
}
|
||||
|
||||
/// <summary>The last code-unit boundary at or before <paramref name="position"/>.</summary>
|
||||
public long AlignDown(long position)
|
||||
{
|
||||
if (position <= ByteOrderMarkLength) return position < ByteOrderMarkLength ? 0 : position;
|
||||
return position - (position - ByteOrderMarkLength) % UnitSize;
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] bytes, params byte[] prefix)
|
||||
{
|
||||
if (bytes.Length < prefix.Length) return false;
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
if (bytes[i] != prefix[i]) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first bytes of a log file: they carry its byte order mark and identify the file, so a
|
||||
/// file replaced under the same name is recognised even when it is already longer than the
|
||||
/// position read so far.
|
||||
/// </summary>
|
||||
internal sealed class LogFileHead
|
||||
{
|
||||
internal const int MaxBytes = 4096;
|
||||
|
||||
private readonly byte[] _bytes;
|
||||
|
||||
private LogFileHead(byte[] bytes)
|
||||
{
|
||||
_bytes = bytes;
|
||||
}
|
||||
|
||||
/// <summary>Up to <see cref="MaxBytes"/> from offset 0. Leaves the stream at an undefined position.</summary>
|
||||
public static LogFileHead Read(Stream stream)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var buffer = new byte[(int)Math.Min(MaxBytes, stream.Length)];
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer, read, buffer.Length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < buffer.Length) Array.Resize(ref buffer, read);
|
||||
return new LogFileHead(buffer);
|
||||
}
|
||||
|
||||
internal static LogFileHead FromBytes(byte[] bytes) => new LogFileHead(bytes);
|
||||
|
||||
public int Length => _bytes.Length;
|
||||
|
||||
public LogTextEncoding Encoding => LogTextEncoding.Detect(_bytes);
|
||||
|
||||
/// <summary>SHA-256 of the first <paramref name="length"/> bytes, Base64.</summary>
|
||||
public string Hash(int length)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
return Convert.ToBase64String(sha.ComputeHash(_bytes, 0, length));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte-exact line reader for the logparser collector. <see cref="Position"/> is the file
|
||||
/// offset right behind the last line returned, so a caller that stops after any line can
|
||||
/// resume exactly there — <see cref="StreamReader"/> only exposes its read-ahead boundary.
|
||||
/// <para>
|
||||
/// Line ends are those of <see cref="StreamReader.ReadLine"/>: "\r\n", "\n" and a lone "\r",
|
||||
/// in code units of the file's <see cref="LogTextEncoding"/>. A "\n" directly after the start
|
||||
/// position is skipped when the unit before it is "\r": the previous run ended on a "\r" whose
|
||||
/// "\n" had not been written yet. A line at end of file without a line end is returned with
|
||||
/// <see cref="LastLineTerminated"/> false; the caller decides whether the writer is still in
|
||||
/// it. A line longer than the cap is consumed without being kept: <see cref="ReadLine"/>
|
||||
/// returns an empty string and sets <see cref="LastLineTruncated"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class LogLineReader
|
||||
{
|
||||
private const int ReadBufferSize = 64 * 1024;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly LogTextEncoding _encoding;
|
||||
private readonly int _unit;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly byte[] _buffer = new byte[ReadBufferSize];
|
||||
private int _pos;
|
||||
private int _len;
|
||||
private long _bufferOffset;
|
||||
private bool _eof;
|
||||
private bool _skipLineFeedAfterCarriageReturn;
|
||||
|
||||
private byte[] _line = new byte[4096];
|
||||
private int _lineLength;
|
||||
|
||||
/// <param name="stream">Seekable stream positioned at a code-unit boundary.</param>
|
||||
public LogLineReader(Stream stream, LogTextEncoding encoding, int maxLineBytes)
|
||||
{
|
||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||
if (encoding == null) throw new ArgumentNullException(nameof(encoding));
|
||||
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
|
||||
_stream = stream;
|
||||
_encoding = encoding;
|
||||
_unit = encoding.UnitSize;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_bufferOffset = stream.Position;
|
||||
_skipLineFeedAfterCarriageReturn = PrecededByCarriageReturn();
|
||||
}
|
||||
|
||||
/// <summary>File offset of the next unread byte, exact.</summary>
|
||||
public long Position => _bufferOffset + _pos;
|
||||
|
||||
/// <summary>File offset of the first byte of the line most recently returned.</summary>
|
||||
public long LastLineStart { get; private set; }
|
||||
|
||||
/// <summary>False for a line that reached end of file without a line end.</summary>
|
||||
public bool LastLineTerminated { get; private set; }
|
||||
|
||||
/// <summary>True when the last line was longer than the cap; it was consumed, not returned.</summary>
|
||||
public bool LastLineTruncated { get; private set; }
|
||||
|
||||
/// <summary>The next line without its line end, or null at end of file.</summary>
|
||||
public string ReadLine()
|
||||
{
|
||||
if (Position == 0 && _encoding.ByteOrderMarkLength > 0 && Ensure(_encoding.ByteOrderMarkLength))
|
||||
_pos += _encoding.ByteOrderMarkLength;
|
||||
|
||||
if (_skipLineFeedAfterCarriageReturn)
|
||||
{
|
||||
_skipLineFeedAfterCarriageReturn = false;
|
||||
if (Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
}
|
||||
|
||||
LastLineStart = Position;
|
||||
LastLineTerminated = false;
|
||||
LastLineTruncated = false;
|
||||
_lineLength = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!Ensure(_unit))
|
||||
{
|
||||
// End of file. A partial code unit (a writer mid-character) belongs to the line.
|
||||
var rest = _len - _pos;
|
||||
if (rest > 0)
|
||||
{
|
||||
Append(_pos, rest);
|
||||
_pos = _len;
|
||||
}
|
||||
return Position > LastLineStart ? Decode() : null;
|
||||
}
|
||||
|
||||
// Scan the whole code units in the buffer for the next line end and take the text
|
||||
// before it in one copy.
|
||||
var end = _pos + (_len - _pos) / _unit * _unit;
|
||||
var scan = _pos;
|
||||
while (scan < end && !IsLineEnd(scan))
|
||||
scan += _unit;
|
||||
if (scan > _pos)
|
||||
{
|
||||
Append(_pos, scan - _pos);
|
||||
_pos = scan;
|
||||
}
|
||||
if (scan == end)
|
||||
continue; // no line end in the buffer yet — refill
|
||||
|
||||
var carriageReturn = !UnitAt(_pos, _encoding.LineFeed);
|
||||
_pos += _unit;
|
||||
if (carriageReturn && Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
LastLineTerminated = true;
|
||||
return Decode();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLineEnd(int offset)
|
||||
{
|
||||
if (_unit == 1)
|
||||
{
|
||||
var b = _buffer[offset];
|
||||
return b == (byte)'\n' || b == (byte)'\r';
|
||||
}
|
||||
return UnitAt(offset, _encoding.LineFeed) || UnitAt(offset, _encoding.CarriageReturn);
|
||||
}
|
||||
|
||||
private bool PrecededByCarriageReturn()
|
||||
{
|
||||
var start = _bufferOffset;
|
||||
if (start - _unit < _encoding.ByteOrderMarkLength) return false;
|
||||
var previous = new byte[_unit];
|
||||
_stream.Seek(start - _unit, SeekOrigin.Begin);
|
||||
var read = 0;
|
||||
while (read < _unit)
|
||||
{
|
||||
var n = _stream.Read(previous, read, _unit - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
_stream.Seek(start, SeekOrigin.Begin);
|
||||
if (read < _unit) return false;
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (previous[i] != _encoding.CarriageReturn[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>At least <paramref name="count"/> unread bytes in the buffer, unless the file ends first.</summary>
|
||||
private bool Ensure(int count)
|
||||
{
|
||||
while (_len - _pos < count)
|
||||
{
|
||||
if (_eof) return false;
|
||||
var remaining = _len - _pos;
|
||||
if (_pos > 0)
|
||||
{
|
||||
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, remaining);
|
||||
_bufferOffset += _pos;
|
||||
_pos = 0;
|
||||
_len = remaining;
|
||||
}
|
||||
var read = _stream.Read(_buffer, _len, _buffer.Length - _len);
|
||||
if (read <= 0)
|
||||
{
|
||||
_eof = true;
|
||||
return _len - _pos >= count;
|
||||
}
|
||||
_len += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool UnitAt(int offset, byte[] unit)
|
||||
{
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (_buffer[offset + i] != unit[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Append(int offset, int count)
|
||||
{
|
||||
var room = _maxLineBytes - _lineLength;
|
||||
if (count > room)
|
||||
{
|
||||
LastLineTruncated = true;
|
||||
count = room;
|
||||
if (count <= 0) return;
|
||||
}
|
||||
if (_lineLength + count > _line.Length)
|
||||
{
|
||||
var grown = new byte[Math.Max(_line.Length * 2, _lineLength + count)];
|
||||
Buffer.BlockCopy(_line, 0, grown, 0, _lineLength);
|
||||
_line = grown;
|
||||
}
|
||||
Buffer.BlockCopy(_buffer, offset, _line, _lineLength, count);
|
||||
_lineLength += count;
|
||||
}
|
||||
|
||||
private string Decode() =>
|
||||
_lineLength == 0 || LastLineTruncated ? string.Empty : _encoding.Encoding.GetString(_line, 0, _lineLength);
|
||||
}
|
||||
}
|
||||
+254
-150
@@ -18,6 +18,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public string CollectorType => "logparser";
|
||||
|
||||
/// <summary>A wildcard target reads at most this many files per run, the newest first.</summary>
|
||||
internal const int MaxFilesPerRun = 20;
|
||||
|
||||
/// <summary>Lines one run reads from one file unless the rule sets <c>maxLines</c>.</summary>
|
||||
internal const int DefaultMaxLines = 1000;
|
||||
|
||||
/// <summary>The IME tracker's line bound: a longer physical line is skipped, never matched.</summary>
|
||||
internal const int MaxLineBytes = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the log parser rule. Returns null because logparser emits events directly
|
||||
/// via context.OnEventCollected rather than returning a single result dictionary.
|
||||
@@ -34,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
@@ -84,10 +93,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
|
||||
bool.TryParse(trackPositionStr, out trackPosition);
|
||||
|
||||
// A value that is no positive number keeps the default: a run capped at 0 lines would
|
||||
// never advance its position, and the rule would read nothing for the whole session.
|
||||
string maxLinesStr;
|
||||
int maxLines = 1000;
|
||||
int maxLines = DefaultMaxLines;
|
||||
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
|
||||
int.TryParse(maxLinesStr, out maxLines);
|
||||
{
|
||||
int parsedMaxLines;
|
||||
if (int.TryParse(maxLinesStr, out parsedMaxLines) && parsedMaxLines > 0)
|
||||
maxLines = parsedMaxLines;
|
||||
else
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"maxLines '{maxLinesStr}' is not a positive number — using {DefaultMaxLines}");
|
||||
}
|
||||
|
||||
// Determine format: "cmtrace" (default) or "text" for plain text logs
|
||||
string formatStr;
|
||||
@@ -144,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
long lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
@@ -195,14 +213,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
// Return matched files sorted by last write time (newest first), capped at MaxFilesPerRun
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
if (allMatches.Length > MaxFilesPerRun)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
$"wildcard matched {allMatches.Length} files — capped to the {MaxFilesPerRun} newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.Take(MaxFilesPerRun)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -216,183 +234,248 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
|
||||
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
long startPosition = trackPosition
|
||||
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
|
||||
: 0;
|
||||
var state = context.SessionState;
|
||||
var known = trackPosition ? state.PeekLogPosition(rule.RuleId, filePath) : null;
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
// Nothing new since the last run — decided from the length alone, without opening the file.
|
||||
var length = new FileInfo(filePath).Length;
|
||||
if (known != null && known.Position == length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
$"{fileName}: no new content (position {known.Position} >= length {length}) — trackPosition=true, nothing re-read" +
|
||||
(known.FromPreviousRun ? "; position saved by a previous agent run" : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// A last line without a line end is usually one its writer is still in. An interval
|
||||
// rule leaves it for its next run, once. A one-shot trigger may have no next run, so it
|
||||
// reads the line now but saves the position before it: the next run reads it again,
|
||||
// whole — at worst a match repeats, it is never split.
|
||||
var isInterval = string.Equals(rule.Trigger, "interval", StringComparison.OrdinalIgnoreCase);
|
||||
var mayHoldTail = trackPosition && isInterval;
|
||||
var tailReadAgainNextRun = false;
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int committedLines = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
int oversizedCount = 0;
|
||||
long startPosition;
|
||||
long endPosition;
|
||||
long lineNumberBase;
|
||||
long fileLength;
|
||||
long heldTailStart = -1;
|
||||
LogFileHead head;
|
||||
LogTextEncoding encoding;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
fileLength = stream.Length;
|
||||
head = LogFileHead.Read(stream);
|
||||
encoding = head.Encoding;
|
||||
|
||||
LogReadStart start;
|
||||
startPosition = encoding.AlignDown(GatherRuleSessionState.ResolveStart(known, fileLength, head, out start));
|
||||
lineNumberBase = start == LogReadStart.Continue ? known.LineNumber : 0;
|
||||
TraceReadStart(context, rule, fileName, known, start, fileLength);
|
||||
|
||||
stream.Seek(startPosition, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, MaxLineBytes);
|
||||
endPosition = startPosition;
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
while (linesRead < maxLines)
|
||||
{
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
|
||||
var line = reader.ReadLine();
|
||||
if (line == null)
|
||||
{
|
||||
linesRead++;
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = linesRead;
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
// End of file. A "\n" the reader skipped behind the "\r" the previous run
|
||||
// ended on is consumed too — unless a kept tail must be read again.
|
||||
if (!tailReadAgainNextRun)
|
||||
endPosition = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
// Capture the final stream position after reading
|
||||
endPosition = stream.Position;
|
||||
var unfinished = !reader.LastLineTerminated;
|
||||
if (unfinished && mayHoldTail
|
||||
&& (known == null || known.HeldTailStart != reader.LastLineStart))
|
||||
{
|
||||
heldTailStart = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
linesRead++;
|
||||
var lineNumber = lineNumberBase + linesRead;
|
||||
if (unfinished && trackPosition && !isInterval)
|
||||
{
|
||||
tailReadAgainNextRun = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
endPosition = reader.Position;
|
||||
committedLines = linesRead;
|
||||
}
|
||||
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
oversizedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = lineNumber;
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved only now, after every event of this file went out: a process that dies
|
||||
// before this line repeats the file's new lines after the restart, never skips them.
|
||||
if (trackPosition)
|
||||
context.FilePositionTracker.SetPosition(filePath, endPosition);
|
||||
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 {Path.GetFileName(filePath)}");
|
||||
context.Logger.Debug($"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,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
$"{fileName}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}, encoding={encoding.Name}" +
|
||||
(oversizedCount > 0 ? $", skipped {oversizedCount} line(s) over {MaxLineBytes / (1024 * 1024)} MB" : ""));
|
||||
|
||||
if (heldTailStart >= 0)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — left for the next run, read then even if still unfinished");
|
||||
else if (tailReadAgainNextRun)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — read now and again by the next run, position kept before it");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
if (linesRead == maxLines && endPosition < fileLength)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
@@ -403,6 +486,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceReadStart(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
LogPositionSnapshot known, LogReadStart start, long fileLength)
|
||||
{
|
||||
if (start == LogReadStart.Shrunk)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file is shorter than the saved position (length {fileLength} < {known.Position}) — truncated or recreated, reading from the beginning");
|
||||
}
|
||||
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.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");
|
||||
}
|
||||
else if (start == LogReadStart.Continue && known.FromPreviousRun)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: continuing at position {known.Position} (after line {known.LineNumber}) saved by a previous agent run");
|
||||
}
|
||||
}
|
||||
|
||||
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
|
||||
{
|
||||
// If the rule specifies a severity, use it
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+6
-4
@@ -4,7 +4,6 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -19,7 +18,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public string TenantId { get; }
|
||||
public Action<EnrollmentEvent> OnEventCollected { get; }
|
||||
public string ImeLogPathOverride { get; }
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
|
||||
/// <summary>Logparser positions per (rule, file) and on_change values; survives agent restarts when persisted.</summary>
|
||||
public GatherRuleSessionState SessionState { get; }
|
||||
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -34,7 +36,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleSessionState sessionState,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -42,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
SessionState = sessionState ?? throw new ArgumentNullException(nameof(sessionState));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
|
||||
+56
-9
@@ -10,7 +10,6 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -38,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleSessionState _state;
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -50,12 +50,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
|
||||
// Guarded by _scopeLock; collector work always runs outside the lock. The state deliberately
|
||||
// survives UpdateRules config refreshes — an agent restart causes at most one re-emit per
|
||||
// on_change rule (acceptable under the per-enrollment lifecycle).
|
||||
// survives UpdateRules config refreshes. Across agent restarts only the last emitted hash
|
||||
// survives: it is seeded from _state and committed there after each emit, so an unchanged
|
||||
// on_change result stays silent after a reboot. Phase latches, dedup sets and the
|
||||
// suppression streak are process-scoped.
|
||||
private readonly object _scopeLock = new object();
|
||||
private EnrollmentPhase _currentPhase = EnrollmentPhase.Unknown;
|
||||
private readonly HashSet<string> _fromPhaseLatched = new HashSet<string>();
|
||||
private readonly Dictionary<string, string> _lastEmittedHash = new Dictionary<string, string>();
|
||||
private readonly HashSet<string> _hashFromPreviousRun = new HashSet<string>();
|
||||
private readonly Dictionary<string, (int Count, DateTime SinceUtc)> _suppressed =
|
||||
new Dictionary<string, (int Count, DateTime SinceUtc)>();
|
||||
private readonly HashSet<string> _scopeBypassLogged = new HashSet<string>();
|
||||
@@ -77,9 +80,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
set { _context.UnrestrictedMode = value; }
|
||||
}
|
||||
|
||||
/// <param name="stateDirectory">
|
||||
/// Where logparser positions and on_change values are kept across agent restarts. Null keeps
|
||||
/// them in memory — required for <c>--run-gather-rules</c>, which runs beside the live agent
|
||||
/// under a throwaway session id and must not overwrite the session's file.
|
||||
/// </param>
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
Action<string> debugEcho = null, string stateDirectory = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -89,8 +97,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
var persistence = string.IsNullOrEmpty(stateDirectory) ? null : new GatherRuleStatePersistence(stateDirectory, logger);
|
||||
_state = new GatherRuleSessionState(sessionId, persistence, logger);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, _state, _debug);
|
||||
|
||||
foreach (var hash in _state.GetEmittedHashes())
|
||||
{
|
||||
_lastEmittedHash[hash.Key] = hash.Value;
|
||||
_hashFromPreviousRun.Add(hash.Key);
|
||||
}
|
||||
_logger.Info($"GatherRuleExecutor: gather state {_state.RestoreSummary}");
|
||||
DebugLog(null, GatherRuleDebugLog.StageConfig, $"gather state: {_state.RestoreSummary}");
|
||||
foreach (var detail in _state.RestoredDetails)
|
||||
DebugLog(detail.Key, GatherRuleDebugLog.StageConfig, detail.Value);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -480,14 +499,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
string emittedHash = null;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
bool comparedWithPreviousRun;
|
||||
if (!ShouldEmitOnChange(rule, result, out emittedHash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}" +
|
||||
(comparedWithPreviousRun ? "; compared with the value a previous agent run emitted" : ""));
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
@@ -524,6 +546,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
|
||||
// Committed only after the event went out: a process that dies in between emits
|
||||
// the result once more after the restart instead of never.
|
||||
if (emittedHash != null)
|
||||
_state.CommitEmittedHash(rule.RuleId, emittedHash);
|
||||
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
@@ -679,9 +707,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
string hash;
|
||||
bool comparedWithPreviousRun;
|
||||
return ShouldEmitOnChange(rule, result, out hash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full form: <paramref name="hash"/> is the result's canonical hash, committed to the session
|
||||
/// state once the event went out; <paramref name="comparedWithPreviousRun"/> says a suppression
|
||||
/// compared against the value a previous agent run emitted.
|
||||
/// </summary>
|
||||
private bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hash, out string hashPrefix, out int suppressedStreak, out bool comparedWithPreviousRun)
|
||||
{
|
||||
hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
comparedWithPreviousRun = false;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -693,11 +735,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
comparedWithPreviousRun = _hashFromPreviousRun.Contains(rule.RuleId);
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastEmittedHash[rule.RuleId] = hash;
|
||||
_hashFromPreviousRun.Remove(rule.RuleId);
|
||||
(int Count, DateTime SinceUtc) streak;
|
||||
if (_suppressed.TryGetValue(rule.RuleId, out streak) && streak.Count > 0)
|
||||
{
|
||||
@@ -820,6 +864,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
StopAllTimers();
|
||||
_startupRulesLatch?.Dispose();
|
||||
_startupRulesLatch = null;
|
||||
// A rule still finishing on a pool thread keeps its in-memory state but no longer writes:
|
||||
// the agent may be removing its own folder right now.
|
||||
_state.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Gather-rule state of one enrollment session that survives agent restarts: where each
|
||||
/// logparser rule stopped reading each file, and the on_change value each rule last emitted.
|
||||
/// <para>
|
||||
/// Positions are kept per (rule, file) — two rules parsing the same log each read all of it.
|
||||
/// Every position carries a fingerprint of the file's first bytes, so a file replaced under
|
||||
/// the same name is read from the beginning even when it is already longer than the position.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every commit is written to disk synchronously, AFTER the events it covers were handed on:
|
||||
/// a process that dies in between repeats that one pass after the restart instead of losing it.
|
||||
/// The lock guards memory and file write together and is never taken under the executor's
|
||||
/// scope lock. Without a persistence (diagnostic mode, tests) the state lives in memory only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Files remembered per rule. A run reads at most <see cref="LogParserCollector.MaxFilesPerRun"/>
|
||||
/// files; the least recently touched entry beyond the cap is dropped, and its file — should it
|
||||
/// come back — is simply read again.
|
||||
/// </summary>
|
||||
internal const int MaxLogFilesPerRule = 5 * LogParserCollector.MaxFilesPerRun;
|
||||
|
||||
private static readonly char[] WildcardChars = { '*', '?' };
|
||||
|
||||
private readonly object _gate = new object();
|
||||
private readonly string _sessionId;
|
||||
private readonly GatherRuleStatePersistence _persistence;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Dictionary<string, Dictionary<string, Entry>> _logPositions =
|
||||
new Dictionary<string, Dictionary<string, Entry>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _emittedHashes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly List<KeyValuePair<string, string>> _restoredDetails = new List<KeyValuePair<string, string>>();
|
||||
private long _touchCounter;
|
||||
private bool _unsaved;
|
||||
private bool _saveFailureLogged;
|
||||
private bool _closed;
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public long Position;
|
||||
public long LineNumber;
|
||||
public int HeadLength;
|
||||
public string HeadHash;
|
||||
public bool FromPreviousRun;
|
||||
public long HeldTailStart = -1;
|
||||
public long LastTouched;
|
||||
}
|
||||
|
||||
public GatherRuleSessionState(string sessionId, GatherRuleStatePersistence persistence, AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_persistence = persistence;
|
||||
RestoreSummary = Restore();
|
||||
}
|
||||
|
||||
/// <summary>One line on what the constructor found on disk — for the agent log and the debug trace.</summary>
|
||||
public string RestoreSummary { get; }
|
||||
|
||||
/// <summary>(ruleId, message) per restored position and on_change value — for the debug trace.</summary>
|
||||
internal IReadOnlyList<KeyValuePair<string, string>> RestoredDetails => _restoredDetails;
|
||||
|
||||
// ===== logparser positions =====
|
||||
|
||||
/// <summary>The recorded position of <paramref name="filePath"/> for the rule, or null when it has none.</summary>
|
||||
internal LogPositionSnapshot PeekLogPosition(string ruleId, string filePath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
Entry entry;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files) || !files.TryGetValue(filePath, out entry))
|
||||
return null;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
return new LogPositionSnapshot(entry.Position, entry.LineNumber, entry.HeadLength, entry.HeadHash,
|
||||
entry.FromPreviousRun, entry.HeldTailStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records where the rule stopped in the file and saves the state when it changed.
|
||||
/// <paramref name="heldTailStart"/> (in memory only) marks an unfinished last line left for
|
||||
/// the next run; -1 when none.
|
||||
/// </summary>
|
||||
internal void CommitLogPosition(string ruleId, string filePath, long position, long lineNumber,
|
||||
LogFileHead head, long heldTailStart)
|
||||
{
|
||||
var headLength = head.Length;
|
||||
var headHash = headLength > 0 ? head.Hash(headLength) : null;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[ruleId] = files;
|
||||
}
|
||||
|
||||
Entry entry;
|
||||
var changed = false;
|
||||
if (!files.TryGetValue(filePath, out entry))
|
||||
{
|
||||
entry = new Entry();
|
||||
files[filePath] = entry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed || entry.Position != position || entry.LineNumber != lineNumber
|
||||
|| entry.HeadLength != headLength || !string.Equals(entry.HeadHash, headHash, StringComparison.Ordinal))
|
||||
{
|
||||
entry.Position = position;
|
||||
entry.LineNumber = lineNumber;
|
||||
entry.HeadLength = headLength;
|
||||
entry.HeadHash = headHash;
|
||||
entry.FromPreviousRun = false;
|
||||
changed = true;
|
||||
}
|
||||
entry.HeldTailStart = heldTailStart;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(ruleId, files);
|
||||
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where to continue reading: the recorded position while the file is still the one it was
|
||||
/// recorded for, otherwise 0 with the reason.
|
||||
/// </summary>
|
||||
internal static long ResolveStart(LogPositionSnapshot known, long fileLength, LogFileHead head, out LogReadStart start)
|
||||
{
|
||||
if (known == null)
|
||||
{
|
||||
start = LogReadStart.FirstRead;
|
||||
return 0;
|
||||
}
|
||||
if (fileLength < known.Position)
|
||||
{
|
||||
start = LogReadStart.Shrunk;
|
||||
return 0;
|
||||
}
|
||||
if (known.HeadLength > 0
|
||||
&& (head.Length < known.HeadLength
|
||||
|| !string.Equals(head.Hash(known.HeadLength), known.HeadHash, StringComparison.Ordinal)))
|
||||
{
|
||||
start = LogReadStart.Replaced;
|
||||
return 0;
|
||||
}
|
||||
start = LogReadStart.Continue;
|
||||
return known.Position;
|
||||
}
|
||||
|
||||
// ===== on_change values =====
|
||||
|
||||
/// <summary>Rule id → hash of the result the rule last emitted.</summary>
|
||||
internal Dictionary<string, string> GetEmittedHashes()
|
||||
{
|
||||
lock (_gate)
|
||||
return new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Records the hash of a result that was just emitted and saves the state when it changed.</summary>
|
||||
internal void CommitEmittedHash(string ruleId, string hash)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string current;
|
||||
var changed = !_emittedHashes.TryGetValue(ruleId, out current)
|
||||
|| !string.Equals(current, hash, StringComparison.Ordinal);
|
||||
_emittedHashes[ruleId] = hash;
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retries a save that failed earlier, then stops writing (executor disposed).</summary>
|
||||
internal void Close()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_unsaved)
|
||||
SaveLocked();
|
||||
_closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== persistence =====
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
if (_persistence == null || _closed)
|
||||
{
|
||||
_unsaved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = _logPositions
|
||||
.SelectMany(rule => rule.Value.Select(file => new { RuleId = rule.Key, Path = file.Key, Entry = file.Value }))
|
||||
.OrderBy(x => x.Entry.LastTouched)
|
||||
.Select(x => new GatherLogPositionData
|
||||
{
|
||||
RuleId = x.RuleId,
|
||||
Path = x.Path,
|
||||
Position = x.Entry.Position,
|
||||
LineNumber = x.Entry.LineNumber,
|
||||
HeadLength = x.Entry.HeadLength,
|
||||
HeadHash = x.Entry.HeadHash,
|
||||
})
|
||||
.ToList(),
|
||||
OnChangeHashes = new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
if (_persistence.Save(data))
|
||||
{
|
||||
_unsaved = false;
|
||||
_saveFailureLogged = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_unsaved = true;
|
||||
if (!_saveFailureLogged)
|
||||
{
|
||||
_saveFailureLogged = true;
|
||||
_logger.Warning($"GatherRuleSessionState: saving {_persistence.StateFilePath} failed — kept in memory, retried at the next change");
|
||||
}
|
||||
}
|
||||
|
||||
private string Restore()
|
||||
{
|
||||
if (_persistence == null)
|
||||
return "persistence off (no state directory) — positions and on_change values live in memory only";
|
||||
|
||||
string discardReason;
|
||||
var data = _persistence.Load(_sessionId, out discardReason);
|
||||
if (data == null)
|
||||
{
|
||||
return discardReason == null
|
||||
? "no saved state — first agent run of this session"
|
||||
: $"saved state discarded ({discardReason}) — starting fresh";
|
||||
}
|
||||
|
||||
// The file is input like any other: whatever it holds, the executor must come up. A
|
||||
// failure here would otherwise stop every collector host, at every restart of the session.
|
||||
try
|
||||
{
|
||||
return RestoreFrom(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logPositions.Clear();
|
||||
_emittedHashes.Clear();
|
||||
_restoredDetails.Clear();
|
||||
_logger.Warning($"GatherRuleSessionState: restoring {_persistence.StateFilePath} failed, starting fresh: {ex.Message}");
|
||||
return $"saved state discarded (restore failed: {ex.Message}) — starting fresh";
|
||||
}
|
||||
}
|
||||
|
||||
private string RestoreFrom(GatherRuleStateData data)
|
||||
{
|
||||
var dropped = 0;
|
||||
foreach (var position in data.LogPositions ?? new List<GatherLogPositionData>())
|
||||
{
|
||||
if (!IsValid(position))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(position.RuleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[position.RuleId] = files;
|
||||
}
|
||||
files[position.Path] = new Entry
|
||||
{
|
||||
Position = position.Position,
|
||||
LineNumber = position.LineNumber,
|
||||
HeadLength = position.HeadLength,
|
||||
HeadHash = position.HeadLength > 0 ? position.HeadHash : null,
|
||||
FromPreviousRun = true,
|
||||
LastTouched = ++_touchCounter,
|
||||
};
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(position.RuleId, files);
|
||||
}
|
||||
|
||||
foreach (var hash in data.OnChangeHashes ?? new Dictionary<string, string>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash.Key) || string.IsNullOrEmpty(hash.Value))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
_emittedHashes[hash.Key] = hash.Value;
|
||||
}
|
||||
|
||||
foreach (var rule in _logPositions)
|
||||
{
|
||||
foreach (var file in rule.Value)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(rule.Key,
|
||||
$"logparser position restored: {Path.GetFileName(file.Key)} at byte {file.Value.Position}, after line {file.Value.LineNumber}"));
|
||||
}
|
||||
}
|
||||
foreach (var hash in _emittedHashes)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(hash.Key,
|
||||
$"on_change value restored (hash {Prefix(hash.Value)}) — an unchanged result stays silent"));
|
||||
}
|
||||
|
||||
var positionCount = _logPositions.Sum(rule => rule.Value.Count);
|
||||
return $"restored {positionCount} logparser position(s) for {_logPositions.Count} rule(s) and {_emittedHashes.Count} on_change value(s), " +
|
||||
$"saved {data.SavedAtUtc:yyyy-MM-ddTHH:mm:ssZ} by a previous agent run of this session" +
|
||||
(dropped > 0 ? $"; {dropped} invalid entr{(dropped == 1 ? "y" : "ies")} dropped" : "") +
|
||||
" — logparser rules continue after their saved position, on_change rules stay silent until their result changes";
|
||||
}
|
||||
|
||||
private static bool IsValid(GatherLogPositionData position)
|
||||
{
|
||||
if (position == null || string.IsNullOrEmpty(position.RuleId) || string.IsNullOrEmpty(position.Path))
|
||||
return false;
|
||||
// A real resolved path never carries these; Path APIs throw on them under .NET Framework.
|
||||
if (position.Path.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || position.Path.IndexOfAny(WildcardChars) >= 0)
|
||||
return false;
|
||||
if (position.Position < 0 || position.LineNumber < 0)
|
||||
return false;
|
||||
if (position.HeadLength < 0 || position.HeadLength > LogFileHead.MaxBytes)
|
||||
return false;
|
||||
if (position.HeadLength > 0 && string.IsNullOrEmpty(position.HeadHash))
|
||||
return false;
|
||||
// Reading any byte records a non-empty head; a position without one cannot be verified.
|
||||
return position.Position == 0 || position.HeadLength > 0;
|
||||
}
|
||||
|
||||
private void EvictLeastRecentlyTouched(string ruleId, Dictionary<string, Entry> files)
|
||||
{
|
||||
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)}");
|
||||
}
|
||||
|
||||
private static string Prefix(string hash) => hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
}
|
||||
|
||||
/// <summary>Immutable copy of a recorded logparser position.</summary>
|
||||
internal sealed class LogPositionSnapshot
|
||||
{
|
||||
public LogPositionSnapshot(long position, long lineNumber, int headLength, string headHash,
|
||||
bool fromPreviousRun, long heldTailStart)
|
||||
{
|
||||
Position = position;
|
||||
LineNumber = lineNumber;
|
||||
HeadLength = headLength;
|
||||
HeadHash = headHash;
|
||||
FromPreviousRun = fromPreviousRun;
|
||||
HeldTailStart = heldTailStart;
|
||||
}
|
||||
|
||||
public long Position { get; }
|
||||
public long LineNumber { get; }
|
||||
public int HeadLength { get; }
|
||||
public string HeadHash { get; }
|
||||
|
||||
/// <summary>Restored from disk and not advanced by this process yet.</summary>
|
||||
public bool FromPreviousRun { get; }
|
||||
|
||||
/// <summary>Start of an unfinished last line left for this run by the previous one; -1 when none.</summary>
|
||||
public long HeldTailStart { get; }
|
||||
}
|
||||
|
||||
/// <summary>How <see cref="GatherRuleSessionState.ResolveStart"/> decided where reading starts.</summary>
|
||||
internal enum LogReadStart
|
||||
{
|
||||
FirstRead,
|
||||
Continue,
|
||||
Shrunk,
|
||||
Replaced,
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <see cref="GatherRuleSessionState"/> in the agent's state directory so one
|
||||
/// enrollment's logparser positions and on_change values survive agent restarts, reboots and
|
||||
/// the WhiteGlove Part 2 resume.
|
||||
/// <para>
|
||||
/// The state is only resumed for the SAME session id: the state directory is one fixed folder
|
||||
/// that a session rotation does not clear, and the new session starts empty in the backend, so
|
||||
/// it must read its logs from the beginning.
|
||||
/// </para>
|
||||
/// Fail-soft like <c>BandwidthStatePersistence</c>: I/O errors never throw; a missing, corrupt
|
||||
/// or foreign file loads as null (fresh start — the pre-persistence behavior).
|
||||
/// </summary>
|
||||
public class GatherRuleStatePersistence
|
||||
{
|
||||
public const string FileName = "gather-rule-state.json";
|
||||
|
||||
private const string SourceName = "GatherRuleStatePersistence";
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleStatePersistence(string stateDirectory, AgentLogger logger)
|
||||
{
|
||||
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
|
||||
_stateFilePath = Path.Combine(_stateDirectory, FileName);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string StateFilePath => _stateFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted state, or null when absent, unreadable, or written for a different
|
||||
/// session. <paramref name="discardReason"/> is null when there simply was no file.
|
||||
/// </summary>
|
||||
public GatherRuleStateData Load(string expectedSessionId, out string discardReason)
|
||||
{
|
||||
discardReason = null;
|
||||
if (!File.Exists(_stateFilePath)) return null;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var state = JsonConvert.DeserializeObject<GatherRuleStateData>(json);
|
||||
if (state == null || string.IsNullOrEmpty(state.SessionId))
|
||||
{
|
||||
discardReason = "state file empty or invalid";
|
||||
_logger.Warning($"[{SourceName}] persisted state file was empty or invalid — starting fresh");
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(state.SessionId, expectedSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
discardReason = $"saved for session {state.SessionId}, current session is {expectedSessionId}";
|
||||
_logger.Warning($"[{SourceName}] persisted state belongs to session {state.SessionId}, not {expectedSessionId} — starting fresh");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
discardReason = $"state file unreadable: {ex.Message}";
|
||||
_logger.Warning($"[{SourceName}] failed to load persisted state, starting fresh: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persists the state (atomic write via temp file). Never throws; false when the write failed.</summary>
|
||||
public bool Save(GatherRuleStateData state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state));
|
||||
if (File.Exists(_stateFilePath))
|
||||
{
|
||||
File.Replace(tempPath, _stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, _stateFilePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTOs for state serialization ----
|
||||
|
||||
public class GatherRuleStateData
|
||||
{
|
||||
public string SessionId { get; set; }
|
||||
public DateTime SavedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Least recently used first, so a restore keeps the eviction order.</summary>
|
||||
public List<GatherLogPositionData> LogPositions { get; set; }
|
||||
|
||||
/// <summary>Rule id → canonical hash of the result the rule last emitted (on_change).</summary>
|
||||
public Dictionary<string, string> OnChangeHashes { get; set; }
|
||||
}
|
||||
|
||||
public class GatherLogPositionData
|
||||
{
|
||||
public string RuleId { get; set; }
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>File offset right behind the last line the rule processed.</summary>
|
||||
public long Position { get; set; }
|
||||
|
||||
/// <summary>Lines before <see cref="Position"/> — the base for <c>logLineNumber</c>.</summary>
|
||||
public long LineNumber { get; set; }
|
||||
|
||||
/// <summary>Bytes from the start of the file the fingerprint covers (0..4096).</summary>
|
||||
public int HeadLength { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of those bytes, Base64; null when <see cref="HeadLength"/> is 0.</summary>
|
||||
public string HeadHash { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_informationalEvents = informationalEvents;
|
||||
_parkedTripwireDwell = parkedTripwireDwell ?? DefaultParkedTripwireDwell;
|
||||
|
||||
// If recovery loaded a state that already sits on a terminal stage (e.g. a crash
|
||||
// after a success-path step but before Stop()), treat it as already-notified so we
|
||||
// do not re-fire the hook.
|
||||
_terminalNotified = initialState.Stage.IsTerminal();
|
||||
// If recovery loaded a state that already ends the session (e.g. a crash after a
|
||||
// success-path step but before Stop()), treat it as already-notified so we do not
|
||||
// re-fire the hook — the orchestrator handles that state at start.
|
||||
_terminalNotified = EndsSession(initialState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the state ends this agent's session: a terminal stage, or the engine stopped
|
||||
/// waiting for the user without a verdict (<see cref="DecisionState.StoppedWaitingReason"/>,
|
||||
/// D-310) — the stage stays non-terminal there, and the agent ends the session like its
|
||||
/// max-lifetime watchdog.
|
||||
/// </summary>
|
||||
internal static bool EndsSession(DecisionState state) =>
|
||||
state.Stage.IsTerminal() || state.StoppedWaitingReason != null;
|
||||
|
||||
public DecisionState CurrentState => _currentState;
|
||||
|
||||
/// <summary>Test-Observability — anzahl aufeinanderfolgender Journal-Failures.</summary>
|
||||
@@ -284,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// orchestrator turns this into the public EnrollmentTerminated event so peripheral
|
||||
// consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react
|
||||
// without touching the kernel state machine.
|
||||
if (!_terminalNotified && _currentState.Stage.IsTerminal())
|
||||
if (!_terminalNotified && EndsSession(_currentState))
|
||||
{
|
||||
_terminalNotified = true;
|
||||
try { _onTerminalStageReached?.Invoke(_currentState); }
|
||||
@@ -405,7 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private static bool IsParkedWithoutDeadline(DecisionState state)
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (EndsSession(state)) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
|
||||
@@ -479,6 +479,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (collectors.StallProbeEnabled)
|
||||
{
|
||||
// A pending tenant Hello wait (up to 60 min) is a bounded wait that resolves the
|
||||
// session either way — the probe holds session_stalled back until it is over.
|
||||
Func<bool>? helloWaitPending = null;
|
||||
if (decisionStateProbe != null)
|
||||
helloWaitPending = () => DecisionEngine.IsTenantHelloWaitPending(decisionStateProbe(), clock.UtcNow);
|
||||
|
||||
hosts.Add(new StallProbeHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
@@ -490,7 +496,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation));
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
helloWaitPending: helloWaitPending));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
@@ -540,9 +547,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
stateDirectory: _stateDirectory);
|
||||
hosts.Add(osBuildChangeHost);
|
||||
|
||||
// Windows Update during OOBE watcher — subscribes to WindowsUpdateClient/Operational and
|
||||
// backfills recent events (OOBE quality updates run before the agent starts). Surfaces
|
||||
// quality/cumulative updates installing/failing DURING enrollment — otherwise invisible.
|
||||
// OS update watchers (D-310) — the Windows Update client on the System log (install
|
||||
// events) and its Operational channel (scan/download), plus the CBS servicing steps on
|
||||
// the Setup log; each backfills recent events because updates can start before the
|
||||
// agent does. Surfaces Windows/.NET updates installing/failing DURING enrollment and,
|
||||
// unless OobeUpdatePhaseMode is Off, feeds them into the engine's OOBE update phase.
|
||||
if (collectors.WindowsUpdateWatcherEnabled)
|
||||
{
|
||||
hosts.Add(new WindowsUpdateWatcherHost(
|
||||
@@ -555,7 +564,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
backfillLookbackMinutes: collectors.WindowsUpdateBackfillLookbackMinutes,
|
||||
stateDirectory: _stateDirectory,
|
||||
channelCensusEnabled: collectors.WindowsUpdateChannelCensusEnabled,
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged));
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged,
|
||||
operationalEventIds: collectors.WindowsUpdateOperationalEventIds,
|
||||
servicingWatcherEnabled: collectors.ServicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: DecisionCore.State.OobeUpdatePhaseModes.PostsSignals(collectors.OobeUpdatePhaseMode)));
|
||||
}
|
||||
|
||||
// MDM reboot-policy watcher — subscribes to DeviceManagement-Enterprise-Diagnostics-
|
||||
@@ -729,7 +741,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
|
||||
timelineEvents: timelineEvents));
|
||||
timelineEvents: timelineEvents,
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
|
||||
@@ -740,7 +740,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// itself is constructed inside the onIngressReady hook (step 13b), which has
|
||||
// already fired by this point — so when our recovery-fire propagates through
|
||||
// Task.Run, the handler is fully wired.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed)
|
||||
// The same applies to a state in which the engine stopped waiting (D-310): a
|
||||
// crash between that step and the termination must not leave the session running.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed
|
||||
|| initialState.StoppedWaitingReason != null)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: post-recovery initialState already terminal " +
|
||||
@@ -869,25 +872,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Stop the max-lifetime watchdog — the real terminal arrived before it could trip.
|
||||
try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { }
|
||||
|
||||
var outcome = terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal; the session ends like the max-lifetime watchdog ends it (TimedOut), so
|
||||
// the backend classifies it honestly — never as a failure.
|
||||
var stoppedWaiting = !terminalState.Stage.IsTerminal() && terminalState.StoppedWaitingReason != null;
|
||||
|
||||
var outcome = stoppedWaiting
|
||||
? EnrollmentTerminationOutcome.TimedOut
|
||||
: terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
var reason = stoppedWaiting
|
||||
? EnrollmentTerminationReason.StoppedWaiting
|
||||
: EnrollmentTerminationReason.DecisionTerminalStage;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — dispatching Terminated(DecisionTerminalStage) off worker.");
|
||||
$"EnrollmentOrchestrator: decision ended the session (stage={terminalState.Stage}, outcome={outcome}, reason={reason}) — dispatching Terminated off worker.");
|
||||
|
||||
string? details = null;
|
||||
if (stoppedWaiting)
|
||||
details = StoppedWaitingReasons.Describe(terminalState.StoppedWaitingReason!.Value);
|
||||
else if (terminalState.Stage.IsPauseBeforePart2())
|
||||
details = "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed.";
|
||||
|
||||
var terminatedArgs = new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
reason: reason,
|
||||
outcome: outcome,
|
||||
stageName: terminalState.Stage.ToString(),
|
||||
terminatedAtUtc: _clock.UtcNow,
|
||||
details: terminalState.Stage.IsPauseBeforePart2()
|
||||
? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed."
|
||||
: null);
|
||||
details: details,
|
||||
stopReason: stoppedWaiting ? terminalState.StoppedWaitingReason!.Value : null);
|
||||
|
||||
// Codex Finding 2 — off-worker dispatch. Task.Run unblocks the ingress worker
|
||||
// immediately; the worker keeps draining the channel (including events the
|
||||
|
||||
+16
-1
@@ -16,6 +16,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// longer than the configured cap without reaching a terminal stage.
|
||||
/// </summary>
|
||||
MaxLifetimeExceeded,
|
||||
|
||||
/// <summary>
|
||||
/// The DecisionEngine stopped waiting for the user without a verdict — a bounded wait
|
||||
/// expired (<c>DecisionState.StoppedWaitingReason</c>, D-310). Ends the session like
|
||||
/// <see cref="MaxLifetimeExceeded"/> (outcome TimedOut), with the engine's reason.
|
||||
/// </summary>
|
||||
StoppedWaiting,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,18 +67,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
public DateTime TerminatedAtUtc { get; }
|
||||
public string? Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The engine's reason code for <see cref="EnrollmentTerminationReason.StoppedWaiting"/>
|
||||
/// (e.g. <c>oobe_update_no_sign_in</c>); null for every other reason.
|
||||
/// </summary>
|
||||
public string? StopReason { get; }
|
||||
|
||||
public EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason reason,
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string? stageName,
|
||||
DateTime terminatedAtUtc,
|
||||
string? details = null)
|
||||
string? details = null,
|
||||
string? stopReason = null)
|
||||
{
|
||||
Reason = reason;
|
||||
Outcome = outcome;
|
||||
StageName = stageName;
|
||||
TerminatedAtUtc = terminatedAtUtc;
|
||||
Details = details;
|
||||
StopReason = stopReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
|
||||
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
|
||||
// with the RAW collector payload — before the reducer has applied gates like the
|
||||
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
|
||||
// RealmJoin completion gate. Consequence (session 32312a32): a phase_change
|
||||
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
|
||||
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
|
||||
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
string? stateDirectory = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -66,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
debugLogPath: gatherDebugLogPath, stateDirectory: stateDirectory);
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds,
|
||||
bool isDevicePreparation = false)
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool>? helloWaitPending = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -74,7 +75,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
helloWaitPending: helloWaitPending);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
|
||||
+183
-23
@@ -1,26 +1,41 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standalone host for the <see cref="WindowsUpdateTracker"/> — watches
|
||||
/// <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative update
|
||||
/// activity during OOBE / ESP and forwards it via the shared single-rail ingress.
|
||||
/// Mirrors <see cref="StallProbeHost"/>: builds its own <see cref="InformationalEventPost"/>
|
||||
/// and delegates Start/Stop to the tracker.
|
||||
/// Hosts the OS-update watchers (D-310): the Windows Update client on the System log
|
||||
/// (install events) and on its Operational channel (scan/download events), plus the CBS
|
||||
/// servicing steps on the Setup log. Routes every configured WU EventID to the channel the
|
||||
/// provider manifest assigns it, runs the blind-spot census across all channels, reports the
|
||||
/// counted (non-OS) activity once on stop, and — unless <c>OobeUpdatePhaseMode</c> is Off —
|
||||
/// feeds Windows/.NET update steps into the decision engine as
|
||||
/// <see cref="DecisionSignalKind.OsUpdateActivity"/>.
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateWatcherHost : ICollectorHost
|
||||
{
|
||||
public string Name => "WindowsUpdateTracker";
|
||||
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly WindowsUpdateTracker? _systemTracker;
|
||||
private readonly WindowsUpdateTracker? _operationalTracker;
|
||||
private readonly ServicingTracker? _servicingTracker;
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private int _summaryEmitted;
|
||||
private int _disposed;
|
||||
|
||||
public WindowsUpdateWatcherHost(
|
||||
@@ -33,36 +48,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int backfillLookbackMinutes,
|
||||
string stateDirectory,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool>? osBuildChangedProvider = null)
|
||||
Func<bool>? osBuildChangedProvider = null,
|
||||
int[]? operationalEventIds = null,
|
||||
bool servicingWatcherEnabled = true,
|
||||
bool osUpdateSignalsEnabled = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
targetedEventIds: targetedEventIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider);
|
||||
var (systemIds, operationalIds) = RouteEventIds(targetedEventIds, operationalEventIds);
|
||||
|
||||
Action<string, string, DateTime>? wuActivity = null;
|
||||
Action<string, string, DateTime>? servicingActivity = null;
|
||||
if (osUpdateSignalsEnabled)
|
||||
{
|
||||
wuActivity = (step, update, at) => PostOsUpdateActivity(step, "wu", update, at);
|
||||
servicingActivity = (step, update, at) => PostOsUpdateActivity(step, "servicing", update, at);
|
||||
}
|
||||
|
||||
if (systemIds.Length > 0)
|
||||
{
|
||||
_systemTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: systemIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (operationalIds.Length > 0)
|
||||
{
|
||||
_operationalTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: operationalIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider,
|
||||
channel: WindowsUpdateTracker.Channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (servicingWatcherEnabled)
|
||||
{
|
||||
_servicingTracker = new ServicingTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: servicingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seams — expose the trackers so unit tests can drive their ProcessEvent seams.
|
||||
internal WindowsUpdateTracker? SystemTrackerForTest => _systemTracker;
|
||||
internal WindowsUpdateTracker? OperationalTrackerForTest => _operationalTracker;
|
||||
internal ServicingTracker? ServicingTrackerForTest => _servicingTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Splits the configured WU EventIDs by the channel the provider manifest writes them to.
|
||||
/// An ID configured in either list ends up on its real channel, so a misplaced ID still
|
||||
/// works instead of watching a channel that never carries it.
|
||||
/// </summary>
|
||||
internal static (int[] systemIds, int[] operationalIds) RouteEventIds(int[]? targetedEventIds, int[]? operationalEventIds)
|
||||
{
|
||||
var all = (targetedEventIds ?? Array.Empty<int>())
|
||||
.Concat(operationalEventIds ?? Array.Empty<int>())
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
return (
|
||||
all.Where(WindowsUpdateTracker.IsSystemChannelEventId).ToArray(),
|
||||
all.Where(id => !WindowsUpdateTracker.IsSystemChannelEventId(id)).ToArray());
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_systemTracker?.Start();
|
||||
_operationalTracker?.Start();
|
||||
_servicingTracker?.Start();
|
||||
|
||||
// The census asks "did ANY channel see the update?" — only answerable after every
|
||||
// backfill ran.
|
||||
_operationalTracker?.EmitChannelCensusIfBlind(
|
||||
otherChannelsEmitted: _systemTracker?.EmittedThisRun ?? 0,
|
||||
otherTargetedEventIds: _systemTracker?.TargetedEventIds);
|
||||
|
||||
_logger.Info("WindowsUpdateWatcherHost: started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: stop failed: {ex.Message}"); }
|
||||
try { _systemTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: system watcher stop failed: {ex.Message}"); }
|
||||
try { _operationalTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: operational watcher stop failed: {ex.Message}"); }
|
||||
try { _servicingTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: servicing watcher stop failed: {ex.Message}"); }
|
||||
|
||||
EmitActivitySummaryOnce();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -70,5 +174,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One summary of everything that was counted instead of emitted. Stop runs twice on the
|
||||
/// termination path (explicit stop, then dispose), hence the once-guard.
|
||||
/// </summary>
|
||||
internal void EmitActivitySummaryOnce()
|
||||
{
|
||||
if (_tally.IsEmpty) return;
|
||||
if (Interlocked.Exchange(ref _summaryEmitted, 1) == 1) return;
|
||||
|
||||
try
|
||||
{
|
||||
var counts = _tally.Snapshot();
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var kv in counts) data[kv.Key] = kv.Value;
|
||||
var total = counts.Sum(kv => kv.Value);
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Update activity counted instead of listed: {total} event(s) " +
|
||||
"(Store, Defender and other non-OS updates, repeated scan results, servicing of non-OS packages)",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdateWatcherHost: activity summary failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PostOsUpdateActivity(string step, string source, string update, DateTime occurredAtUtc)
|
||||
{
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = source,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(update)) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.OsUpdateActivity,
|
||||
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
sourceOrigin: source == "servicing" ? "ServicingWatcher" : "WindowsUpdateWatcher",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Raw,
|
||||
identifier: $"os-update-activity:{source}",
|
||||
summary: string.IsNullOrEmpty(update) ? step : $"{step}: {update}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
SharedConstants.EventTypes.ImePatternHits,
|
||||
// Bookkeeping of runs that were deliberately not reported as activity.
|
||||
SharedConstants.EventTypes.ScriptRecurrenceSummary,
|
||||
// Same for the update watchers' tally of Store/Defender/other update events (D-310):
|
||||
// emitted on stop, never progress. Windows/.NET update steps stay activity.
|
||||
SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
|
||||
// the update page — the page's own records (oobe_update_page) stay activity.
|
||||
SharedConstants.EventTypes.OobeUpdateState,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+18
-9
@@ -1227,11 +1227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
case EnrollmentTerminationReason.MaxLifetimeExceeded:
|
||||
reasonTag = "max_lifetime";
|
||||
break;
|
||||
case EnrollmentTerminationReason.StoppedWaiting:
|
||||
// The backend classifies this shutdown honestly, exactly like max_lifetime.
|
||||
reasonTag = "stopped_waiting";
|
||||
break;
|
||||
default:
|
||||
reasonTag = args.Reason.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(args.StopReason)) data["stopReason"] = args.StopReason;
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1240,15 +1254,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
},
|
||||
Message = args.Reason == EnrollmentTerminationReason.StoppedWaiting && !string.IsNullOrEmpty(args.Details)
|
||||
? $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}): {args.Details}"
|
||||
: $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
// No stateDirectory on purpose: this mode runs beside the live agent under a
|
||||
// throwaway session id and must neither resume nor overwrite the session's
|
||||
// gather state (logparser positions, on_change values).
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
|
||||
@@ -526,7 +526,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
|
||||
// enrollment after PR-A's archive-and-reset, so the engine state is
|
||||
// empty and needs the facts re-seeded — same as a first boot.
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger);
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
orchestrator.IngressSink,
|
||||
agentConfig.HelloWaitTimeoutSeconds,
|
||||
logger,
|
||||
remoteConfig.Collectors?.OobeUpdatePhaseMode);
|
||||
|
||||
// V2 parity — post SessionStarted so the reducer establishes the session
|
||||
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
|
||||
|
||||
@@ -314,7 +314,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> signal carrying the
|
||||
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
|
||||
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler.
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler — plus the tenant's
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window, and the mode of
|
||||
/// the OOBE update phase (D-310).
|
||||
/// <para>
|
||||
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
|
||||
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
|
||||
@@ -329,7 +331,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public static void PostEnrollmentFactsObserved(
|
||||
ISignalIngressSink ingressSink,
|
||||
AgentLogger logger)
|
||||
int helloWaitTimeoutSeconds,
|
||||
AgentLogger logger,
|
||||
string oobeUpdatePhaseMode = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -352,6 +356,22 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
};
|
||||
|
||||
// Tenant Hello wait (remote config, not a registry fact) — stamped only when it
|
||||
// widens the engine's built-in hello_safety window, so sessions on the default
|
||||
// post the same payload as before.
|
||||
var helloWaitSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
|
||||
if (helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
|
||||
{
|
||||
payload[SignalPayloadKeys.HelloWaitTimeoutSeconds] =
|
||||
helloWaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// OOBE update phase mode (remote config) — stamped at every start, the same value
|
||||
// the update watchers were wired with: the engine lets the phase act only while
|
||||
// every start of the session said Active, so Off or Shadow is a kill switch.
|
||||
var phaseMode = OobeUpdatePhaseModes.Normalize(oobeUpdatePhaseMode);
|
||||
payload[SignalPayloadKeys.OobeUpdatePhaseMode] = phaseMode;
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "enrollment_registry_facts_read",
|
||||
@@ -364,7 +384,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}, oobeUpdatePhaseMode={phaseMode}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -117,7 +117,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// Session 4910a5a5 (2026-07-23) added `espAdvisoryFailureResolvedUtc` (the failed
|
||||
// category recovered — lifts the advisory's re-arm/rebase guard exemption) and
|
||||
// `espAdvisoryFailureCategory` (which category the advisory was for) → 20.
|
||||
Assert.Equal(20, facts.Count);
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict (no sign-in after the OOBE update) → 21.
|
||||
Assert.Equal(21, facts.Count);
|
||||
Assert.All(facts.Values, v => Assert.Null(v));
|
||||
}
|
||||
|
||||
@@ -242,9 +244,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// that vetoes/retracts the policy-disabled Hello skip → 18. Session 4910a5a5
|
||||
// (2026-07-23) added `espAdvisoryFailureResolvedUtc` + `espAdvisoryFailureCategory`
|
||||
// — advisory-recovery facts that lift the AdvisoryCompletion guard exemption → 20.
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict → 21.
|
||||
// If this number ever changes, both the count expectation AND the actual snapshot
|
||||
// output need to evolve in lockstep.
|
||||
Assert.Equal(20, expectedFactKeys.Count);
|
||||
Assert.Equal(21, expectedFactKeys.Count);
|
||||
|
||||
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
|
||||
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant-extendable Hello window. The agent stamps the tenant's <c>HelloWaitTimeoutSeconds</c>
|
||||
/// on <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> when it exceeds the built-in
|
||||
/// 300 s; every <see cref="DeadlineNames.HelloSafety"/> arm site then uses it, clamped to
|
||||
/// 3600 s. Without the key the window stays exactly the built-in 300 s.
|
||||
/// </summary>
|
||||
public sealed class HelloSafetyTenantWindowTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime EspExit = T0.AddMinutes(6);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 300)]
|
||||
[InlineData("1800", 1800)]
|
||||
[InlineData("301", 301)]
|
||||
[InlineData("3600", 3600)]
|
||||
[InlineData("86400", 3600)] // beyond the ceiling — clamped
|
||||
[InlineData("300", 300)] // not an extension — ignored (the agent never stamps it)
|
||||
[InlineData("120", 300)]
|
||||
public void Esp_exit_arms_hello_safety_with_the_tenant_window(string? helloWait, int expectedSeconds)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, state.Stage);
|
||||
var helloSafety = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(EspExit.AddSeconds(expectedSeconds), helloSafety.DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_window_is_recorded_set_once_with_its_source_ordinal()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit("1800"));
|
||||
|
||||
var fact = state.ScenarioObservations.HelloWaitTimeoutSeconds;
|
||||
Assert.NotNull(fact);
|
||||
Assert.Equal(1800, fact!.Value);
|
||||
Assert.Equal(1, fact.SourceSignalOrdinal);
|
||||
|
||||
// A later re-post (every agent start posts the facts again) never re-bases the window.
|
||||
var repost = engine.Reduce(state, Facts(20, EspExit.AddSeconds(30), "3600"));
|
||||
Assert.Equal(1800, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
|
||||
Assert.Equal(1, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
|
||||
Assert.Empty(repost.Effects);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(1800),
|
||||
Assert.Single(repost.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_surfaces_the_tenant_window_only_when_recorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
|
||||
var extended = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit("2700")));
|
||||
Assert.Contains("hello_wait_extended", extended.SignalsSeen);
|
||||
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(extended.SignalEvidence["helloWaitExtended"]);
|
||||
Assert.Equal(2700, evidence["seconds"]);
|
||||
|
||||
var builtIn = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit(helloWait: null)));
|
||||
Assert.DoesNotContain("hello_wait_extended", builtIn.SignalsSeen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
public void Malformed_tenant_window_is_ignored(string helloWait)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
|
||||
|
||||
Assert.Null(state.ScenarioObservations.HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_tenant_window_extends_the_armed_hello_safety_from_its_original_anchor()
|
||||
{
|
||||
// A backfilled ESP exit armed the built-in window before the facts signal landed.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800"));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
var extended = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(EspExit.AddSeconds(1800), extended.DueAtUtc);
|
||||
Assert.Equal(DecisionSignalKind.DeadlineFired, extended.FiresSignalKind);
|
||||
Assert.Equal(DeadlineNames.HelloSafety, extended.FiresPayload![SignalPayloadKeys.Deadline]);
|
||||
|
||||
// The live scheduler is told to replace its timer (same name → replaced).
|
||||
var effect = Assert.Single(step.Effects);
|
||||
Assert.Equal(DecisionEffectKind.ScheduleDeadline, effect.Kind);
|
||||
Assert.Equal(EspExit.AddSeconds(1800), effect.Deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("300")]
|
||||
[InlineData("120")]
|
||||
public void Late_tenant_window_not_longer_than_the_built_in_leaves_the_deadline_alone(string helloWait)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), helloWait));
|
||||
|
||||
Assert.Empty(step.Effects);
|
||||
Assert.Null(step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fire_of_the_replaced_built_in_deadline_is_dead_ended_after_the_late_extension()
|
||||
{
|
||||
// The old timer fired while the facts signal was still queued ahead of it: the facts
|
||||
// step moves hello_safety out, then the queued fire of the replaced incarnation
|
||||
// arrives. It must not time Hello out at the built-in window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
var builtInDue = EspExit.AddSeconds(300);
|
||||
state = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800")).NewState;
|
||||
|
||||
var stale = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.DeadlineFired, builtInDue.AddSeconds(2),
|
||||
Fired(DeadlineNames.HelloSafety, builtInDue)));
|
||||
|
||||
Assert.False(stale.Transition.Taken);
|
||||
Assert.Equal("hello_safety_stale_superseded_by_rearm", stale.Transition.DeadEndReason);
|
||||
Assert.Empty(stale.Effects);
|
||||
Assert.Null(stale.NewState.HelloResolvedUtc);
|
||||
Assert.Equal(SessionStage.AwaitingHello, stale.NewState.Stage);
|
||||
var extendedDue = EspExit.AddSeconds(1800);
|
||||
Assert.Equal(
|
||||
extendedDue,
|
||||
Assert.Single(stale.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
|
||||
// The fire of the current incarnation still times Hello out (desktop is in → Finalizing).
|
||||
var current = engine.Reduce(stale.NewState, MakeSignal(22, DecisionSignalKind.DeadlineFired, extendedDue,
|
||||
Fired(DeadlineNames.HelloSafety, extendedDue)));
|
||||
Assert.Equal(SessionStage.Finalizing, current.NewState.Stage);
|
||||
Assert.Equal("Timeout", current.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_tenant_window_after_the_built_in_expired_leaves_the_pending_fire_to_decide()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(301), "1800"));
|
||||
|
||||
Assert.Empty(step.Effects);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
// The fact is still recorded — later arm sites use it.
|
||||
Assert.Equal(1800, step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_extension_is_replay_deterministic()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var signals = ClassicUntilEspExit(helloWait: null)
|
||||
.Concat(new[] { Facts(20, EspExit.AddSeconds(10), "2400") })
|
||||
.ToList();
|
||||
|
||||
var stepwise = ReduceAll(engine, signals);
|
||||
var replayed = ReducerReplay.Replay(engine, Seed(), signals);
|
||||
|
||||
Assert.Equal(
|
||||
Assert.Single(stepwise.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc,
|
||||
Assert.Single(replayed.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
Assert.Equal(EspExit.AddSeconds(2400), replayed.Deadlines.Single(d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_window_also_applies_to_the_AccountSetup_complete_deferred_promote()
|
||||
{
|
||||
// Strong gate arrives after the final exit and before the desktop: the deferred
|
||||
// promote arms hello_safety at the gate signal — with the tenant window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
Facts(1, T0.AddSeconds(5), "2400"),
|
||||
MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("DeviceSetup")),
|
||||
MakeSignal(3, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" }),
|
||||
MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(12), Phase("AccountSetup")),
|
||||
MakeSignal(5, DecisionSignalKind.EspExiting, T0.AddMinutes(24)),
|
||||
});
|
||||
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||
|
||||
var gate = T0.AddMinutes(25);
|
||||
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.AccountSetupProvisioningComplete, gate));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
Assert.Equal(
|
||||
gate.AddSeconds(2400),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30 * 60)]
|
||||
[InlineData("1200", 30 * 60)] // a shorter tenant window never shortens the WDP backstop
|
||||
[InlineData("3600", 3600)]
|
||||
public void Device_preparation_backstop_is_never_shorter_than_the_tenant_window(string? helloWait, int expectedSeconds)
|
||||
{
|
||||
// WDP has no ESP, so hello_safety never arms; the desktop-armed backstop is the last
|
||||
// net behind the tracker's Hello completion timer, which runs on the tenant budget.
|
||||
var engine = new DecisionEngine();
|
||||
var facts = new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
|
||||
};
|
||||
if (helloWait != null)
|
||||
facts[SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait;
|
||||
var desktop = T0.AddMinutes(9);
|
||||
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, facts),
|
||||
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
desktop.AddSeconds(expectedSeconds),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantHelloWaitPending_is_true_only_while_an_extended_window_is_armed_and_not_yet_due()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var awaiting = ReduceAll(engine, ClassicUntilEspExit("1800"));
|
||||
var due = EspExit.AddSeconds(1800);
|
||||
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, EspExit.AddMinutes(1)));
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(-1)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(1)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(null, EspExit));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(Seed(), EspExit));
|
||||
|
||||
// The built-in window never counts: default tenants keep their stall reporting.
|
||||
var builtIn = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
Assert.Contains(builtIn.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(builtIn, EspExit.AddMinutes(1)));
|
||||
|
||||
// Hello resolved → the deadline is cancelled → nothing pending any more.
|
||||
var resolved = engine.Reduce(awaiting, MakeSignal(20, DecisionSignalKind.HelloResolved, EspExit.AddMinutes(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" })).NewState;
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(resolved, EspExit.AddMinutes(11)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantHelloWaitPending_covers_the_device_preparation_backstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var desktop = T0.AddMinutes(9);
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
|
||||
[SignalPayloadKeys.HelloWaitTimeoutSeconds] = "3600",
|
||||
}),
|
||||
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
|
||||
});
|
||||
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(59)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(60)));
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static DecisionState Seed() => DecisionState.CreateInitial("sess-hello-window", "tenant", T0);
|
||||
|
||||
/// <summary>
|
||||
/// Classic user-driven flow up to the final ESP exit: strong gate and desktop in place,
|
||||
/// Hello policy unknown — so the exit promotes to AwaitingHello and arms hello_safety.
|
||||
/// </summary>
|
||||
private static List<DecisionSignal> ClassicUntilEspExit(string? helloWait)
|
||||
{
|
||||
var signals = new List<DecisionSignal> { MakeSignal(0, DecisionSignalKind.SessionStarted, T0) };
|
||||
if (helloWait != null)
|
||||
signals.Add(Facts(1, T0.AddSeconds(5), helloWait));
|
||||
signals.Add(MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("AccountSetup")));
|
||||
signals.Add(MakeSignal(3, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(2)));
|
||||
signals.Add(MakeSignal(4, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3)));
|
||||
signals.Add(MakeSignal(5, DecisionSignalKind.EspExiting, EspExit));
|
||||
return signals;
|
||||
}
|
||||
|
||||
private static DecisionState ReduceAll(DecisionEngine engine, IEnumerable<DecisionSignal> signals)
|
||||
{
|
||||
var state = Seed();
|
||||
foreach (var signal in signals)
|
||||
state = engine.Reduce(state, signal).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionSignal Facts(long ordinal, DateTime occurredAtUtc, string helloWait) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait });
|
||||
|
||||
private static Dictionary<string, string> Fired(string deadlineName, DateTime dueAtUtc) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = deadlineName,
|
||||
[SignalPayloadKeys.DeadlineDueAtUtc] = dueAtUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> Phase(string phase) =>
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = phase };
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Report 3a2207978e4c (2026-10-01, D-310) — the OOBE quality update. With the ESP setting
|
||||
/// "Install Windows quality updates" Windows installs the update after the Device-ESP page
|
||||
/// closes and before the user signs in: 20–40 minutes plus a restart to the lock screen. The
|
||||
/// IME had logged its AccountSetup phase line before that handoff exit, so the exit armed the
|
||||
/// 30-min <c>advisory_completion</c> window, and the session was failed
|
||||
/// (<c>esp_exit_without_completion_evidence</c>) while the update was still installing.
|
||||
/// <para>
|
||||
/// The OOBE update phase holds the window, every state bounded on its own (user decision
|
||||
/// 2026-10-02): the update runs at most 3 h, then fails as not finished; after the update the
|
||||
/// user has 60 min to sign in, then the engine stops waiting — no verdict, the backend
|
||||
/// classifies; after the sign-in the user's own setup gets one window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the Active mode acts (measurement first, user decision 2026-10-02). In the Shadow mode
|
||||
/// — the default, and what an unstamped session counts as — the fire reports what the phase
|
||||
/// would decide and today's rule decides; Off evaluates nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OobeUpdatePhaseTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 10, 1, 11, 0, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal OsUpdate(long ordinal, DateTime at, string step, string? update = Lcu)
|
||||
{
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = "wu",
|
||||
};
|
||||
if (update != null) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
return MakeSignal(ordinal, DecisionSignalKind.OsUpdateActivity, at, payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal AdvisoryFired(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion });
|
||||
|
||||
private static DecisionSignal Reboot(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.SystemRebootObserved, at);
|
||||
|
||||
/// <summary>The agent's mode stamp on <c>EnrollmentFactsObserved</c>, posted at every start.</summary>
|
||||
private static DecisionSignal PhaseMode(long ordinal, DateTime at, string mode) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.OobeUpdatePhaseMode] = mode });
|
||||
|
||||
private static bool HasTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Any(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
/// <summary>
|
||||
/// The report's shape up to the arming exit: Classic user-driven, full ESP, DeviceSetup,
|
||||
/// the IME's pre-sign-in AccountSetup line, then the Device-ESP handoff exit at T0+21,
|
||||
/// which arms the window (due T0+51). <paramref name="beforeExit"/> runs before the exit;
|
||||
/// <paramref name="mode"/> is the agent's mode stamp (null: none, an older agent).
|
||||
/// </summary>
|
||||
private static DecisionState HandoffExitArmsTheWindow(
|
||||
DecisionEngine engine,
|
||||
Func<DecisionState, DecisionState>? beforeExit = null,
|
||||
string? mode = OobeUpdatePhaseModes.Active)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-3a220797", "tenant-3a220797", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
if (mode != null) state = engine.Reduce(state, PhaseMode(1, T0, mode)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = "false",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(20),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
if (beforeExit != null) state = beforeExit(state);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(21))).NewState;
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(armed);
|
||||
Assert.Equal(T0.AddMinutes(51), armed!.DueAtUtc);
|
||||
Assert.Null(state.Outcome);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the armed advisory deadline at its due time until a fire does not re-arm it;
|
||||
/// returns every step. Proves each re-arm lands on time — the last one on the bound.
|
||||
/// </summary>
|
||||
private static List<DecisionStep> FireUntilResolved(DecisionEngine engine, DecisionState state, long firstOrdinal)
|
||||
{
|
||||
var steps = new List<DecisionStep>();
|
||||
var ordinal = firstOrdinal;
|
||||
while (true)
|
||||
{
|
||||
var deadline = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(deadline);
|
||||
var step = engine.Reduce(state, AdvisoryFired(ordinal++, deadline!.DueAtUtc));
|
||||
steps.Add(step);
|
||||
state = step.NewState;
|
||||
if (FindDeadline(state, DeadlineNames.AdvisoryCompletion) == null) return steps;
|
||||
Assert.True(steps.Count < 50, "the phase never resolved");
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================= handler ====
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheHandoffExit_AnchorsThePhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var first = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted));
|
||||
Assert.True(first.Transition.Taken);
|
||||
Assert.Equal("OsUpdateActivity:OobeUpdatePhaseStarted", first.Transition.Trigger);
|
||||
Assert.Equal(state.Stage, first.NewState.Stage);
|
||||
Assert.Empty(first.Effects);
|
||||
|
||||
var facts = first.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(40, facts.PostExitActivityUtc.SourceSignalOrdinal);
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(first.NewState));
|
||||
|
||||
var second = engine.Reduce(first.NewState, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted));
|
||||
Assert.Equal("OsUpdateActivity", second.Transition.Trigger);
|
||||
facts = second.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(35), facts.LastActivityUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, facts.LastStep!.Value);
|
||||
Assert.Equal(Lcu, facts.LastUpdate!.Value);
|
||||
Assert.Equal(2, facts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredAndFailure_AreRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(31), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(32), OsUpdateSteps.Failed, update: null)).NewState;
|
||||
|
||||
var facts = state.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(30), facts.RebootRequiredUtc!.Value); // earliest
|
||||
Assert.Equal(T0.AddMinutes(32), facts.FailedUtc!.Value);
|
||||
Assert.Equal("KB5099999", facts.LastUpdate!.Value); // a step without an update keeps the last one
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityWithoutStep_IsADeadEnd()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.OsUpdateActivity, T0.AddMinutes(22),
|
||||
new Dictionary<string, string> { [OsUpdatePayloadKeys.Source] = "wu" }));
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("os_update_activity_without_step", step.Transition.DeadEndReason);
|
||||
Assert.Equal(0, step.NewState.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityBeforeTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
Assert.NotNull(state.OsUpdateFacts.FirstActivityUtc);
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledActivityThatPredatesTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(18), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.Equal(1, state.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheUserSignedIn_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, MakeSignal(35, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(25))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
// ============================================================ restart facts ====
|
||||
|
||||
[Fact]
|
||||
public void RestartDuringThePhase_IsRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(53))).NewState;
|
||||
|
||||
Assert.Equal(T0.AddMinutes(53), state.OsUpdateFacts.RestartUtc!.Value);
|
||||
Assert.Equal(50, state.OsUpdateFacts.RestartUtc.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartBeforeThePhase_OrAfterTheSignIn_IsNotTheUpdatesRestart()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, Reboot(35, T0.AddMinutes(21.5))).NewState; // no anchor yet
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(45, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(30))).NewState;
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(40))).NewState; // user already there
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
}
|
||||
|
||||
// ======================================================= advisory window ====
|
||||
|
||||
[Fact]
|
||||
public void Report3a2207978e4c_FireDuringTheUpdate_RearmsInsteadOfFailing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var fireAt = T0.AddMinutes(51);
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, fireAt));
|
||||
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", step.Transition.Trigger);
|
||||
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
|
||||
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
|
||||
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Contains("installing an update in OOBE", waiting.Parameters!["message"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, waiting.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, waiting.Parameters!["osUpdate"]);
|
||||
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
|
||||
// The update may run 3 h from its first activity.
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), waiting.Parameters!["oobeUpdateHoldsUntilUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutUpdateActivity_TheFireStillFails()
|
||||
{
|
||||
// Mutation proof: the guard keys on observed update activity, never on its absence.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityOnlyBeforeTheExit_TheFireStillFails()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStillRunningAtTheCap_FailsAsNotFinished()
|
||||
{
|
||||
// A hanging update: the last step says it still runs, and no restart ever comes.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
Assert.All(steps.Take(steps.Count - 1), s =>
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", s.Transition.Trigger));
|
||||
var cap = T0.AddMinutes(22).AddHours(3);
|
||||
// The last re-arm lands on the cap, not up to 30 min after it.
|
||||
Assert.Equal(cap, FindDeadline(steps[steps.Count - 2].NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
var resolved = steps.Last();
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, resolved.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateNotFinished", resolved.Transition.Trigger);
|
||||
var failed = SingleTimelineEffect(resolved, "enrollment_failed");
|
||||
Assert.Equal("oobe_update_not_finished", failed.Parameters!["reason"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, failed.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal("3", failed.Parameters!["oobeUpdateCapHours"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateWithRestart_WaitsSixtyMinutesForTheSignIn_ThenStopsWaiting()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.InstallStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
|
||||
var beforeRestart = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", beforeRestart.Transition.Trigger);
|
||||
state = beforeRestart.NewState;
|
||||
|
||||
// The update page restarts the device; the lock screen waits for the user.
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
Assert.Equal(T0.AddMinutes(83), FindDeadline(state, DeadlineNames.AdvisoryCompletion)!.DueAtUtc); // re-based
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var waiting = steps.Take(steps.Count - 1).ToList();
|
||||
Assert.All(waiting, s => Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", s.Transition.Trigger));
|
||||
Assert.Contains("waiting for the user to sign in",
|
||||
SingleTimelineEffect(waiting[0], "completion_waiting").Parameters!["message"]);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(53).AddMinutes(60), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired", stopped.Transition.Trigger);
|
||||
Assert.True(stopped.Transition.Taken);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
// No verdict: the stage stays, nothing is failed, nothing is armed any more.
|
||||
Assert.Equal(state.Stage, stopped.NewState.Stage);
|
||||
Assert.Null(stopped.NewState.Outcome);
|
||||
Assert.Empty(stopped.NewState.Deadlines);
|
||||
|
||||
var expired = SingleTimelineEffect(stopped, "completion_wait_expired");
|
||||
Assert.Equal("Warning", expired.Parameters!["severity"]);
|
||||
Assert.Equal("true", expired.Parameters!["immediateUpload"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, expired.Parameters!["reason"]);
|
||||
Assert.Equal("60", expired.Parameters!["signInWindowMinutes"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), expired.Parameters!["oobeUpdateEndedUtc"]);
|
||||
Assert.Contains("Not a failure", expired.Parameters!["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateEndingWithoutRestart_SignInWindowStartsAtItsLastStep()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.Installed)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(100), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledOlderStepAfterTheRestart_DoesNotReopenTheUpdate()
|
||||
{
|
||||
// The watchers backfill after the restart: a record written before it arrives late.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(45), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, state.OsUpdateFacts.LastStep!.Value);
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.AwaitingSignIn, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(113), phase.BoundUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerUpdateStepAfterTheRestart_ReopensTheUpdate_BoundedByTheCap()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(60), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.Updating, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3), phase.BoundUtc); // the cap counts from the first activity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSignIn_GetsOneFullWindow_ThenTheFireResolvesNormally()
|
||||
{
|
||||
// The transition to AwaitingHello does not cancel the window. Without the grace a fire
|
||||
// right after the sign-in would fail the session inside the Hello wizard.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51))).NewState; // due T0+81
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(75))).NewState;
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
// 6 minutes after the sign-in — inside the grace, which ends at T0+105.
|
||||
var grace = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(81)));
|
||||
Assert.Null(grace.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhaseUserArrived", grace.Transition.Trigger);
|
||||
Assert.Contains("signed in after the OOBE update", SingleTimelineEffect(grace, "completion_waiting").Parameters!["message"]);
|
||||
Assert.Equal(T0.AddMinutes(105), FindDeadline(grace.NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
// One full window after the sign-in, still no completion evidence — resolved the normal way.
|
||||
var resolved = engine.Reduce(grace.NewState, AdvisoryFired(80, T0.AddMinutes(105)));
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(resolved, "enrollment_failed").Parameters!["reason"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
// Active decides itself — no shadow report.
|
||||
Assert.False(HasTimelineEffect(resolved, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryVariant_IsExempt()
|
||||
{
|
||||
// Mutation proof: an unresolved ESP terminal failure does not un-happen because
|
||||
// Windows installs an update afterwards.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
var builder = state.ToBuilder();
|
||||
builder.EspAdvisoryFailureRecordedUtc = new SignalFact<DateTime>(T0.AddMinutes(21), 30);
|
||||
state = builder.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_terminal_failure", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPhase_WithoutAnAnchor()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.None,
|
||||
DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(51)).Kind);
|
||||
}
|
||||
|
||||
// ================================================= serialization + census ====
|
||||
|
||||
[Fact]
|
||||
public void SnapshotRoundTrip_PreservesTheUpdateFacts_AndTheStop()
|
||||
{
|
||||
// The update ends with a restart; the agent restores the engine state from its
|
||||
// snapshot, so the phase — and a decided stop — must survive it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(stopped));
|
||||
|
||||
var facts = roundtripped.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(48), facts.RebootRequiredUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(53), facts.RestartUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, facts.LastStep!.Value);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, roundtripped.StoppedWaitingReason!.Value);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, roundtripped.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacySnapshot_WithoutTheNewFacts_DeserializesToEmpty()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var legacy = JObject.Parse(StateSerializer.Serialize(state));
|
||||
foreach (var prop in legacy.Properties()
|
||||
.Where(p => p.Name.IndexOf("OsUpdateFacts", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| p.Name.IndexOf("StoppedWaitingReason", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.ToList())
|
||||
{
|
||||
prop.Remove();
|
||||
}
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(legacy.ToString());
|
||||
Assert.Same(OsUpdateFacts.Empty, roundtripped.OsUpdateFacts);
|
||||
Assert.Null(roundtripped.StoppedWaitingReason);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(roundtripped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_SurfacesThePhase_AndTheStop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
Assert.DoesNotContain("oobe_update_phase", DecisionStateSignalCensus.Build(state).SignalsSeen);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.Installed)).NewState;
|
||||
var census = DecisionStateSignalCensus.Build(state);
|
||||
Assert.Contains("oobe_update_phase", census.SignalsSeen);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), census.SignalTimestamps["oobeUpdatePhase"]);
|
||||
Assert.DoesNotContain("stopped_waiting", census.SignalsSeen);
|
||||
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
Assert.Contains("stopped_waiting", DecisionStateSignalCensus.Build(stopped).SignalsSeen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OobeUpdatePhaseModes.Active, "Active")]
|
||||
[InlineData(OobeUpdatePhaseModes.Shadow, "Shadow")]
|
||||
[InlineData(null, "Shadow")] // never stamped
|
||||
public void Census_NamesTheModeThePhaseRanIn(string? mode, string expected)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: mode);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var evidence = (IDictionary<string, object>)DecisionStateSignalCensus.Build(state).SignalEvidence["oobeUpdatePhase"];
|
||||
Assert.Equal(expected, evidence["mode"]);
|
||||
}
|
||||
|
||||
// ======================================================= measurement mode ====
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_ReportsWhatThePhaseWouldDecide_AndTodaysRuleDecides()
|
||||
{
|
||||
// Report 3a2207978e4c in the Shadow mode: the fire mid-update fails the session as
|
||||
// before, and says that the phase would have kept waiting.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion", step.Transition.Trigger);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
|
||||
var shadow = SingleTimelineEffect(step, "oobe_update_phase_shadow");
|
||||
var parameters = shadow.Parameters!;
|
||||
Assert.Equal("Debug", parameters["severity"]);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, parameters["phaseMode"]);
|
||||
Assert.Equal("update_running", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", parameters["decidedReason"]);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), parameters["oobeUpdatePhaseStartedUtc"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, parameters["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, parameters["osUpdate"]);
|
||||
Assert.Contains("still installing an update", parameters["message"]);
|
||||
// The report precedes the verdict on the timeline.
|
||||
Assert.True(step.Effects.ToList().IndexOf(shadow) < step.Effects.ToList().IndexOf(SingleTimelineEffect(step, "enrollment_failed")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_AfterTheUpdatesRestart_ReportsTheSignInWait()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState; // re-bases the window to T0+83
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("awaiting_sign_in", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), parameters["oobeUpdateEndedUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(113).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_HangingUpdate_ReportsTheNotFinishedFailure()
|
||||
{
|
||||
// A fire after the cap (a shadow session lives this long only when another guard held
|
||||
// it, e.g. enforcement progress) reports the not-finished failure the phase would give.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.InstallStarted)).NewState;
|
||||
var afterCap = T0.AddMinutes(22).AddHours(3).AddMinutes(5);
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion)!;
|
||||
state = state.ToBuilder()
|
||||
.CancelDeadline(DeadlineNames.AdvisoryCompletion)
|
||||
.AddDeadline(new ActiveDeadline(armed.Name, afterCap, armed.FiresSignalKind, armed.FiresPayload))
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, afterCap));
|
||||
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("update_not_finished", parameters["phaseState"]);
|
||||
Assert.Equal("fail_oobe_update_not_finished", parameters["wouldDecide"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnstampedSession_CountsAsShadow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: null);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("update_running", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffMode_EvaluatesNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Off);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_WithoutPhase_ReportsNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeStamp_ActiveCanOnlyBeTakenAway_NeverGranted()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(DecisionState.CreateInitial("s", "t", T0), MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
// A later agent start in Shadow or Off takes Active away (kill switch) ...
|
||||
var state = engine.Reduce(initial, PhaseMode(1, T0, "active")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
state = engine.Reduce(state, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(1, state.ScenarioObservations.OobeUpdatePhaseMode!.SourceSignalOrdinal);
|
||||
state = engine.Reduce(state, PhaseMode(3, T0.AddMinutes(90), OobeUpdatePhaseModes.Off)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
Assert.Equal(3, state.ScenarioObservations.OobeUpdatePhaseMode.SourceSignalOrdinal);
|
||||
|
||||
// ... and switching it on mid-session applies from the next session only.
|
||||
state = engine.Reduce(state, PhaseMode(4, T0.AddMinutes(120), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
|
||||
var shadow = engine.Reduce(initial, PhaseMode(1, T0, "unknown-mode")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
shadow = engine.Reduce(shadow, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveTakenAwayAfterTheRestart_TheFireFallsBackToTodaysRule()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine); // Active
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
// The agent comes back after the update's restart with the phase switched back to Shadow.
|
||||
state = engine.Reduce(state, PhaseMode(61, T0.AddMinutes(54), OobeUpdatePhaseModes.Shadow)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("awaiting_sign_in", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
|
||||
.WithCloudPc(value: true, sourceSignalOrdinal: 7)
|
||||
.WithAccountSetupProgressAtWhiteGloveSuccess(value: false, sourceSignalOrdinal: 8))
|
||||
.WithAccountSetupProgressAtWhiteGloveSuccess(value: false, sourceSignalOrdinal: 8)
|
||||
.WithHelloWaitTimeoutSeconds(value: 1800, sourceSignalOrdinal: 9))
|
||||
.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
@@ -541,6 +542,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
|
||||
Assert.False(o.AccountSetupProgressAtWhiteGloveSuccess!.Value);
|
||||
Assert.Equal(8, o.AccountSetupProgressAtWhiteGloveSuccess!.SourceSignalOrdinal);
|
||||
Assert.Equal(1800, o.HelloWaitTimeoutSeconds!.Value);
|
||||
Assert.Equal(9, o.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
|
||||
|
||||
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
|
||||
Assert.Null(o.WhiteGloveSealingPatternSeen);
|
||||
|
||||
+5
@@ -90,6 +90,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
lastClassifierVerdictId: "verdict-" + seed));
|
||||
}
|
||||
if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
|
||||
if (type == typeof(OsUpdateFacts))
|
||||
{
|
||||
return OsUpdateFacts.Empty.WithActivity(
|
||||
BaseUtc.AddMinutes(seed), ordinal, OsUpdateSteps.RebootRequired, "KB-" + seed, isPostExit: true);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"No value factory for builder property type {type}. A new DecisionState field " +
|
||||
|
||||
@@ -207,8 +207,8 @@ public class BuiltInRulesTests
|
||||
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
|
||||
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
|
||||
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
|
||||
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
|
||||
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
|
||||
/// pinned here against the real IME line (verified against IME 1.104.102.0) because a
|
||||
/// silent drift would bring back
|
||||
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
|
||||
/// into the product identity the exposure summary groups by.
|
||||
/// </summary>
|
||||
public class CpeProductKeyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
|
||||
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
|
||||
[InlineData("cpe:2.3:a:*:*:1.0", "")]
|
||||
[InlineData("cpe:2.3:a", "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe));
|
||||
}
|
||||
@@ -122,4 +122,22 @@ public class CveIndexEntityMergeTests
|
||||
|
||||
Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Product_IsTheCpeVendorAndProduct_ofTheWinningOccurrence()
|
||||
{
|
||||
var zip = Finding("7-Zip 24.08 (x64 edition)", "high", Vuln("CVE-1", 7.8, "HIGH"), Vuln("CVE-2", 3.3, "LOW"));
|
||||
zip["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:24.08";
|
||||
var teams = Finding("Microsoft Teams", "high", Vuln("CVE-1", 8.8, "HIGH"));
|
||||
teams["cpeUri"] = "cpe:2.3:a:microsoft:teams:1.6.00.4472";
|
||||
var noCpe = Finding("Contoso Tool", "high", Vuln("CVE-3", 7.0, "HIGH"));
|
||||
|
||||
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>> { zip, teams, noCpe });
|
||||
|
||||
var cve1 = entities.Single(x => x.GetString("CveId") == "CVE-1");
|
||||
Assert.Equal("Microsoft Teams", cve1.GetString("SoftwareName")); // name and product move together
|
||||
Assert.Equal("microsoft:teams", cve1.GetString("Product"));
|
||||
Assert.Equal("7-zip:7-zip", entities.Single(x => x.GetString("CveId") == "CVE-2").GetString("Product"));
|
||||
Assert.Equal("", entities.Single(x => x.GetString("CveId") == "CVE-3").GetString("Product"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,17 +584,18 @@ public class DelegatedAdminServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameGroup_DoesNotInvalidateCache()
|
||||
public async Task UpdateGroup_DoesNotInvalidateCache()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.RenameGroupAsync(GroupId1, "Renamed");
|
||||
await svc.GetScopeAsync(Id(Upn)); // name-only change → scope stays cached
|
||||
await svc.UpdateGroupAsync(GroupId1, "Renamed", "Service Desk");
|
||||
await svc.GetScopeAsync(Id(Upn)); // name/label change → scope stays cached
|
||||
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId1, "Renamed", "Service Desk"), Times.Once);
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,4 +563,62 @@ public class DelegationSelfServiceTests
|
||||
Assert.False(group.Revocable);
|
||||
Assert.Equal(2, group.Assignees.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListManagers_TwoLabelledOperatorGroups_AreDistinguishable_DirectGrantsStayNeutral()
|
||||
{
|
||||
const string serviceGroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
||||
const string careGroupId = "1a2b3c4d5e6f70819293a4b5c6d7e8f9";
|
||||
var h = Build();
|
||||
h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List<string> { serviceGroupId, careGroupId });
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(serviceGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = serviceGroupId, Name = "Internal managed service tenants", CustomerLabel = "Contoso Managed Service Team",
|
||||
TenantIds = new List<string> { Customer },
|
||||
});
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(careGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = careGroupId, Name = "Internal care unit", CustomerLabel = " Contoso Customer Care Unit ",
|
||||
TenantIds = new List<string> { Customer },
|
||||
});
|
||||
h.Repo.Setup(x => x.GetDelegatedAssigneesAsync(Customer)).ReturnsAsync(new List<DelegatedAdminEntry>
|
||||
{
|
||||
new() { Upn = "ops@vendor.example", TenantId = Customer, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, Status = Constants.DelegatedStatus.Active, GrantedAt = Now.AddDays(-10) },
|
||||
});
|
||||
|
||||
var managers = await h.Svc.ListManagersAsync(Customer);
|
||||
|
||||
Assert.Equal("Contoso Managed Service Team", managers.Single(m => m.GroupId == serviceGroupId).Name);
|
||||
Assert.Equal("Contoso Customer Care Unit", managers.Single(m => m.GroupId == careGroupId).Name);
|
||||
Assert.All(managers.Where(m => m.GroupId != null), m =>
|
||||
{
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, m.Source);
|
||||
Assert.False(m.Revocable);
|
||||
Assert.DoesNotContain("Internal", m.Name, StringComparison.Ordinal);
|
||||
});
|
||||
// A direct grant has no group, hence no label — it keeps the neutral name.
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, managers.Single(m => m.GroupId == null).Name);
|
||||
}
|
||||
|
||||
// ── Customer-facing group name ──────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Platform support")]
|
||||
[InlineData("", "Platform support")]
|
||||
[InlineData(" ", "Platform support")]
|
||||
[InlineData(" Contoso Service Desk ", "Contoso Service Desk")]
|
||||
public void CustomerNameOf_OperatorGroup_LabelElseNeutral_NeverTheInternalName(string? label, string expected)
|
||||
{
|
||||
var group = new TenantGroup { GroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0", Name = "Internal name", CustomerLabel = label };
|
||||
|
||||
Assert.Equal(expected, DelegationSelfService.CustomerNameOf(group));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomerNameOf_OwnedGroup_IsItsName_LabelIgnored()
|
||||
{
|
||||
var group = new TenantGroup { GroupId = GroupId, Name = "partner.example", OwnerTenantId = Home, CustomerLabel = "Ignored" };
|
||||
|
||||
Assert.Equal("partner.example", DelegationSelfService.CustomerNameOf(group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +422,31 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_engine_stopped_waiting_after_the_oobe_update_is_never_failed()
|
||||
{
|
||||
// D-310: nobody signed in within the sign-in window after the OOBE update, the engine
|
||||
// stopped waiting and the agent ended the session. Same honest classification as the
|
||||
// watchdog — user-driven terminalizes as Incomplete without naming the watchdog, a
|
||||
// WhiteGlove Part 2 stays AwaitingUser.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), stopped }, hoursSinceStart: 2);
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
Assert.Contains("stopped waiting for the user", reason);
|
||||
Assert.DoesNotContain("max-lifetime", reason);
|
||||
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), stopped });
|
||||
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(2), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
|
||||
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
|
||||
|
||||
[Fact]
|
||||
@@ -429,12 +454,21 @@ public class EnrollmentTimeoutClassifierTests
|
||||
{
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
|
||||
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).AgentStoppedForGood);
|
||||
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
|
||||
// Other shutdown reasons say nothing about max lifetime.
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedForGood);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedWaiting);
|
||||
// D-310: the engine's bounded wait expired — the agent is gone just the same.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedForGood);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedWaiting);
|
||||
// Other shutdown reasons say nothing about the agent being gone for good.
|
||||
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).AgentStoppedForGood);
|
||||
// A real failure event does not raise the app flag.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant Hello wait (<see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/>): a changed value
|
||||
/// must lie in 30..3600 s. Like the retention cap, only a CHANGED value is checked, so a value
|
||||
/// stored before the rule existed never blocks an unrelated save or a backup revert (both
|
||||
/// validate the whole model).
|
||||
/// </summary>
|
||||
public class HelloWaitTimeoutConfigTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Theory]
|
||||
[InlineData(30)]
|
||||
[InlineData(300)]
|
||||
[InlineData(1800)]
|
||||
[InlineData(3600)]
|
||||
public void ValidateModel_accepts_the_supported_range(int seconds)
|
||||
{
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = seconds;
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(29)]
|
||||
[InlineData(3601)]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void ValidateModel_rejects_a_changed_value_outside_the_range(int seconds)
|
||||
{
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = seconds;
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: true);
|
||||
|
||||
Assert.Equal("Hello wait timeout must be between 30 and 3600 seconds.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_lets_an_unchanged_out_of_range_value_through()
|
||||
{
|
||||
// Written through the API before the range was validated — an unrelated save (or a
|
||||
// revert to a snapshot holding the same value) must not fail on it.
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.HelloWaitTimeoutSeconds = 86400;
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = 86400;
|
||||
candidate.ContactEmail = "it@example.com";
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_is_the_built_in_five_minutes()
|
||||
{
|
||||
Assert.Equal(300, TenantConfiguration.CreateDefault(TenantId).HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(300, new CollectorConfiguration().HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(HelloWaitTimeout.DefaultSeconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stored_old_default_stays_valid_and_keeps_the_built_in_window()
|
||||
{
|
||||
// Every tenant that ever saved the agent settings page has the old 30 s default stored.
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.HelloWaitTimeoutSeconds = 30;
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = 30;
|
||||
candidate.ContactEmail = "it@example.com";
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(30));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, 300)]
|
||||
[InlineData(300, 300)]
|
||||
[InlineData(301, 301)]
|
||||
[InlineData(3600, 3600)]
|
||||
[InlineData(86400, 3600)]
|
||||
[InlineData(0, 300)]
|
||||
public void EffectiveSeconds_keeps_the_built_in_window_as_floor_and_caps_at_one_hour(int configured, int expected)
|
||||
{
|
||||
Assert.Equal(expected, HelloWaitTimeout.EffectiveSeconds(configured));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Row_without_the_column_reads_as_the_default()
|
||||
{
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
|
||||
entity.Remove("HelloWaitTimeoutSeconds");
|
||||
|
||||
Assert.Equal(300, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stored_value_is_read_verbatim()
|
||||
{
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
|
||||
entity["HelloWaitTimeoutSeconds"] = 30;
|
||||
|
||||
Assert.Equal(30, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_value_roundtrips_store_and_map()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.HelloWaitTimeoutSeconds = 2400;
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(2400, mapped.HelloWaitTimeoutSeconds);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,37 @@ public class IngestMaxLifetimeShutdownTests
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(null));
|
||||
}
|
||||
|
||||
// -------- stopped_waiting (D-310) --------
|
||||
// The engine stopped waiting for the user (no sign-in after the OOBE update) and the agent
|
||||
// ended the session like its watchdog: same contract, classified through the same path.
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaitingReason_IsDetected_AsGaveUp_ButNotAsMaxLifetime()
|
||||
{
|
||||
var stopped = Shutdown("stopped_waiting");
|
||||
|
||||
Assert.True(EventIngestProcessor.IsStoppedWaitingAgentShutdown(stopped));
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(stopped));
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(stopped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLifetimeReason_IsAlsoGaveUp()
|
||||
{
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown("max_lifetime")));
|
||||
Assert.False(EventIngestProcessor.IsStoppedWaitingAgentShutdown(Shutdown("max_lifetime")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("decision_terminal")]
|
||||
[InlineData("ctrl_c")]
|
||||
[InlineData(null)]
|
||||
public void OtherShutdowns_AreNotGaveUp(string? reason)
|
||||
{
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown(reason)));
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(null));
|
||||
}
|
||||
|
||||
// -------- IsAgentTimeoutFailure (misclassification audit 2026-07-16) --------
|
||||
// The V1-parity companion: enrollment_failed(failureType=agent_timeout) emitted by
|
||||
// LifecycleEmitters.CreateMaxLifetimeEmitter. Same semantics as the shutdown shape —
|
||||
|
||||
@@ -373,4 +373,106 @@ public class RevokeEnforcementTests
|
||||
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// --- Customer-visible audit rows name the group as the customer sees it, never internally ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Platform support")]
|
||||
[InlineData("Contoso Service Desk", "Contoso Service Desk")]
|
||||
public async Task UnassignCore_AuditNamesTheGroupByItsCustomerName(string? label, string expected)
|
||||
{
|
||||
var (fn, repo, audit, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = GroupId,
|
||||
Name = "Internal MSP customers north",
|
||||
CustomerLabel = label,
|
||||
TenantIds = new List<string> { TenantA },
|
||||
AssigneeCount = 1,
|
||||
});
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(UpnLower))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment> { new() { Upn = UpnLower, GroupId = GroupId } });
|
||||
repo.Setup(r => r.UnassignGroupAsync(UpnLower, GroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.UnassignCoreAsync(GroupId, Upn, "ga@vendor.example");
|
||||
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", UpnLower, "ga@vendor.example",
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Group"] == expected && d["GroupId"] == GroupId)), Times.Once);
|
||||
}
|
||||
|
||||
// --- Group update: name and/or customer label (PATCH only what changes) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, "name or customerLabel is required")]
|
||||
[InlineData(" ", null, "name must not be empty")]
|
||||
[InlineData("Renamed", null, null)]
|
||||
[InlineData(null, "", null)] // clears the label
|
||||
[InlineData(null, "Contoso Service Desk", null)]
|
||||
public void ValidateUpdateRequest_Shapes(string? name, string? label, string? expectedError)
|
||||
=> Assert.Equal(expectedError,
|
||||
TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { Name = name, CustomerLabel = label }));
|
||||
|
||||
[Fact]
|
||||
public void ValidateUpdateRequest_LabelLongerThanCap_Rejected_TrimmedLengthCounts()
|
||||
{
|
||||
var atCap = " " + new string('x', DelegationSelfService.CustomerLabelMaxLength) + " ";
|
||||
Assert.Null(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = atCap }));
|
||||
|
||||
var tooLong = new string('x', DelegationSelfService.CustomerLabelMaxLength + 1);
|
||||
Assert.NotNull(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = tooLong }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_OnOperatorGroup_WritesOnlyTheLabel_NoAudit()
|
||||
{
|
||||
var (fn, repo, audit, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup { GroupId = GroupId, Name = "MSP Customers" });
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk")).ReturnsAsync(true);
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Contoso Service Desk" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk"), Times.Once);
|
||||
VerifyNoAudit(audit); // naming is not an access change
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_OnOwnedGroup_Refused_NothingWritten()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn();
|
||||
var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(OwnerTenant);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(ownedGroupId)).ReturnsAsync(new TenantGroup { GroupId = ownedGroupId, Name = "partner.example", OwnerTenantId = OwnerTenant });
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(ownedGroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.LabelOnOwnedGroup, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_UnknownGroup_NotFound()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn(); // default: no group
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_RenameOnly_LeavesTheLabelAlone_MissIsNotFound()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null)).ReturnsAsync(true);
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated,
|
||||
await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null), Times.Once);
|
||||
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync("tpl-missing", "Renamed", null)).ReturnsAsync(false);
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound,
|
||||
await fn.UpdateGroupCoreAsync("tpl-missing", new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A stored RuleResult keeps the rule's {{token}} template. The web and the MCP server resolve
|
||||
/// it when they render; a notification has no renderer, so the backend resolves it before the
|
||||
/// text leaves. Three things are pinned here: the backend resolution equals the shared cases
|
||||
/// every renderer runs, no placeholder reaches a notification for any built-in rule, and the
|
||||
/// cut happens after the resolution and on a text boundary.
|
||||
/// </summary>
|
||||
public class RuleTemplateInterpolatorTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "0f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b";
|
||||
private const string FailedAppName = "Contoso Productivity Suite";
|
||||
|
||||
// ── Shared cases (tests/fixtures/rule-template-interpolation) ─────────
|
||||
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", "rule-template-interpolation", "cases.json");
|
||||
// No date parsing: a timestamp-looking string must stay the string the renderers see.
|
||||
return JsonConvert.DeserializeObject<JObject>(
|
||||
File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!;
|
||||
});
|
||||
|
||||
public static IEnumerable<object[]> CaseNames()
|
||||
=> Fixture.Value["cases"]!.Select(c => new object[] { c.Value<string>("name")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseNames))]
|
||||
public void Interpolate_matches_the_shared_case(string name)
|
||||
{
|
||||
var testCase = Fixture.Value["cases"]!.Single(c => c.Value<string>("name") == name);
|
||||
var template = testCase.Value<string?>("template");
|
||||
var expected = testCase.Value<string>("expected")!;
|
||||
var evidence = testCase["matchedConditions"] as JObject;
|
||||
|
||||
// Evidence as the rule engine builds it (nested dictionaries) ...
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsDictionaries(evidence)));
|
||||
// ... and as a JSON read without normalization leaves it (JSON nodes as values).
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsJsonNodes(evidence)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present_and_cover_every_auto_field()
|
||||
{
|
||||
Assert.True(Fixture.Value["cases"]!.Count() >= 20, "cases.json lost cases — the parity guard is only as wide as the file.");
|
||||
Assert.Equal(
|
||||
Fixture.Value["autoFields"]!.ToObject<string[]>()!,
|
||||
RuleEngine.EvidenceAutoFields);
|
||||
}
|
||||
|
||||
// ── Notification variant ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Notification_drops_a_list_item_whose_value_was_not_recorded()
|
||||
{
|
||||
var text = "Facts:\n\n- **Reason:** `{{reason}}`\n- **ESP HRESULT:** `{{errorCode}}`\n* **App:** {{appName}}\n1. step {{exitCode}}\n\nDone.";
|
||||
var evidence = new Dictionary<string, object>
|
||||
{
|
||||
["enrollment_failed_reason"] = new Dictionary<string, object> { ["field"] = "reason", ["value"] = "hello_timeout" },
|
||||
};
|
||||
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(text, evidence);
|
||||
|
||||
Assert.Equal("Facts:\n\n- **Reason:** `hello_timeout`\n\nDone.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_replaces_an_unrecorded_value_in_running_text()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"App **{{appName}}** failed with HRESULT **{{errorCode}}**.",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["failed_app"] = new Dictionary<string, object> { ["field"] = "appName", ["value"] = FailedAppName },
|
||||
});
|
||||
|
||||
Assert.Equal($"App **{FailedAppName}** failed with HRESULT **–**.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_removes_the_sentence_about_literal_placeholders()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"See ANALYZE-APP-013 for the root cause. Any field still shown as a literal `{{…}}` placeholder was simply not recorded. Check the timeline.",
|
||||
new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("See ANALYZE-APP-013 for the root cause. Check the timeline.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_without_evidence_or_text_is_safe()
|
||||
{
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification(null, null));
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification("", new Dictionary<string, object>()));
|
||||
Assert.Equal("Reason: –", RuleTemplateInterpolator.InterpolateForNotification("Reason: {{reason}}", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_collapses_the_gap_a_dropped_paragraph_leaves()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"First.\n\nOnly a literal `{{…}}` remark.\n\nLast.", new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("First.\n\nLast.", rendered);
|
||||
}
|
||||
|
||||
// ── No placeholder leaves the backend ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_builtin_rule_sends_a_placeholder_whatever_the_evidence()
|
||||
{
|
||||
// Worst case for every rule: nothing recorded at all. Both alert builders are the only
|
||||
// paths that put a rule explanation into a notification.
|
||||
var offenders = new List<string>();
|
||||
var templated = 0;
|
||||
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
if (rule.Explanation.Contains("{{")) templated++;
|
||||
|
||||
var result = new RuleResult
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
RuleTitle = rule.Title,
|
||||
Severity = "critical",
|
||||
Category = rule.Category,
|
||||
Explanation = rule.Explanation,
|
||||
};
|
||||
|
||||
var fired = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
var enrollment = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
NotificationAlertBuilder.AddRuleResultSections(enrollment, new List<RuleResult> { result });
|
||||
|
||||
foreach (var section in fired.Sections.Concat(enrollment.Sections))
|
||||
{
|
||||
if (section.Text.Contains("{{") || section.Text.Contains("}}"))
|
||||
offenders.Add($"{rule.RuleId}: {section.Text}");
|
||||
Assert.True(section.Text.Length <= NotificationAlertBuilder.RuleFiredExplanationMaxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the catalog really contains templated rules, so an empty offender
|
||||
// list is a statement about them and not about a catalog without placeholders.
|
||||
Assert.True(templated >= 10, $"Only {templated} built-in rules carry a placeholder — did the catalog load?");
|
||||
Assert.True(offenders.Count == 0, "Placeholder reached a notification:\n" + string.Join("\n", offenders));
|
||||
}
|
||||
|
||||
// ── End to end: engine evidence → alert ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_shows_the_values_the_engine_recorded()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.Equal(
|
||||
"The Autopilot enrollment has explicitly failed.\n\n" +
|
||||
"**Key facts extracted from this session's events:**\n\n" +
|
||||
"- **Failure reason:** `esp_terminal_failure`\n" +
|
||||
$"- **First failed app:** `{FailedAppName}`\n" +
|
||||
"- **ESP failed subcategory:** `Apps`\n" +
|
||||
"- **ESP HRESULT:** `0x87d1041c` …",
|
||||
text);
|
||||
// The stored result is untouched: the web and the MCP server still resolve the template.
|
||||
Assert.Contains("{{reason}}", result.Explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_reads_evidence_that_went_through_the_table()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var fresh = NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text;
|
||||
|
||||
// Same write and read as TableStorageService: JSON column, then nested dictionaries.
|
||||
var json = JsonConvert.SerializeObject(result.MatchedConditions);
|
||||
result.MatchedConditions = AsDictionaries(JObject.Parse(json))!;
|
||||
|
||||
Assert.Equal(fresh, NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_omits_the_facts_a_non_esp_failure_never_had()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: false);
|
||||
|
||||
var text = Assert.Single(NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections).Text;
|
||||
|
||||
Assert.Contains("- **Failure reason:** `hello_timeout`", text);
|
||||
Assert.DoesNotContain("First failed app", text);
|
||||
Assert.DoesNotContain("ESP failed subcategory", text);
|
||||
Assert.DoesNotContain("ESP HRESULT:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrollment_alert_rule_section_is_resolved_and_cut_on_a_line()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, new List<RuleResult> { result });
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.True(text.Length <= NotificationAlertBuilder.RuleSectionExplanationMaxLength);
|
||||
Assert.EndsWith("- **Failure reason:** `esp_terminal_failure` …", text);
|
||||
}
|
||||
|
||||
// ── Cut ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Truncate_keeps_short_text()
|
||||
=> Assert.Equal("short", NotificationAlertBuilder.TruncateAtBoundary("short", 300));
|
||||
|
||||
[Fact]
|
||||
public void Truncate_prefers_a_line_break_then_a_sentence_then_a_word()
|
||||
{
|
||||
var lines = "First paragraph of the text.\n" + new string('a', 40);
|
||||
Assert.Equal("First paragraph of the text. …", NotificationAlertBuilder.TruncateAtBoundary(lines, 50));
|
||||
|
||||
var sentences = "One sentence here. Another sentence that runs on and on without an end";
|
||||
Assert.Equal("One sentence here. …", NotificationAlertBuilder.TruncateAtBoundary(sentences, 30));
|
||||
|
||||
var words = "word word word word word word word word word word";
|
||||
Assert.Equal("word word word word word …", NotificationAlertBuilder.TruncateAtBoundary(words, 30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_never_exceeds_the_limit_and_survives_text_without_any_boundary()
|
||||
{
|
||||
var unbroken = new string('x', 500);
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(unbroken, 100);
|
||||
|
||||
Assert.Equal(100, cut.Length);
|
||||
Assert.EndsWith(" …", cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_ignores_a_boundary_that_would_waste_most_of_the_budget()
|
||||
{
|
||||
var text = "Hi.\n" + string.Join(" ", Enumerable.Repeat("word", 60));
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(text, 100);
|
||||
|
||||
Assert.True(cut.Length > 50, cut);
|
||||
Assert.True(cut.Length <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_does_not_leave_a_bold_or_code_span_open()
|
||||
{
|
||||
var bold = "See the **App Detection Failure During ESP** rule for the cause and the **remediation steps in detail** below";
|
||||
Assert.Equal("See the **App Detection Failure During ESP** rule for the cause and the remediation steps …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(bold, 94));
|
||||
|
||||
var code = "The installer returned `1603 fatal error during installation` and stopped the whole chain";
|
||||
Assert.Equal("The installer returned 1603 fatal error during …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(code, 50));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<RuleResult> RunEnrollmentFailedRuleAsync(bool withEspFailure)
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().Single(r => r.RuleId == "ANALYZE-ENRL-001");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Event("enrollment_failed", 163, new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = withEspFailure ? "esp_terminal_failure" : "hello_timeout",
|
||||
}),
|
||||
};
|
||||
if (withEspFailure)
|
||||
{
|
||||
events.Add(Event("esp_failure_settle_started", 161, new Dictionary<string, object>
|
||||
{
|
||||
["failedSubcategory"] = "Apps",
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
events.Add(Event("app_install_failed", 158, new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = "00000000-0000-4000-8000-000000000001",
|
||||
["appName"] = FailedAppName,
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
}
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleTemplateInterpolatorTests>.Instance);
|
||||
|
||||
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
return Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, long sequence, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = sequence,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object>? AsJsonNodes(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => (object)p.Value);
|
||||
|
||||
private static Dictionary<string, object>? AsDictionaries(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!);
|
||||
|
||||
private static object? Plain(JToken token) => token switch
|
||||
{
|
||||
JObject o => o.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!),
|
||||
JArray a => a.Select(Plain).ToList(),
|
||||
JValue v => v.Value,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,18 @@ public class VerdictCalibrationRadarTests
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaiting_counts_as_a_classifier_verdict_but_never_as_silence()
|
||||
{
|
||||
// D-310: the engine deliberately stopped waiting for the user — not an agent that went
|
||||
// quiet, so the agent-liveness share must not see it.
|
||||
var rows = Horizon(w => new[] { ("sweep:r6", "Incomplete", 2), ("stopwait:r5_incomplete", "Incomplete", 1), ("agent:complete", "Succeeded", 17) });
|
||||
var silence = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.SilenceShareRegression }, rows, Target);
|
||||
Assert.Equal((14, 140, 56, 560), silence);
|
||||
var gap = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.EvidenceGap }, rows, Target);
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_the_horizon_are_ignored()
|
||||
{
|
||||
|
||||
@@ -62,6 +62,8 @@ public class VerdictPathTests
|
||||
Assert.True(VerdictPaths.IsClassifierPath(path));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("sweep:r5_assumed"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r5_incomplete"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r1b_awaiting"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.AgentComplete));
|
||||
|
||||
+241
-1
@@ -14,13 +14,14 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
private static CveExposureEntry Row(
|
||||
string cve, string session, string severity, double score,
|
||||
bool kev = false, string tenant = "t1", string software = "Acme App",
|
||||
DateTime? detectedAt = null)
|
||||
DateTime? detectedAt = null, string product = "")
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenant,
|
||||
CveId = cve,
|
||||
SessionId = session,
|
||||
SoftwareName = software,
|
||||
Product = product,
|
||||
CvssScore = score,
|
||||
CvssSeverity = severity,
|
||||
IsKev = kev,
|
||||
@@ -168,6 +169,245 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.True(r.Truncated);
|
||||
Assert.Equal(365, r.WindowDays); // clamped from 9999
|
||||
Assert.Empty(r.TopCves);
|
||||
Assert.Empty(r.TopSoftware);
|
||||
Assert.Equal(0, r.TotalAffectedSessions);
|
||||
Assert.Equal(0, r.DistinctSoftware);
|
||||
Assert.Equal(0, r.KevAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopCves_AppendKevCvesBelowTheCut_inDeviceOrder()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-MID", "s1", "High", 8.0), Row("CVE-MID", "s2", "High", 8.0),
|
||||
Row("CVE-KEV", "s4", "High", 7.8, kev: true),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE", "CVE-KEV" }, r.TopCves.Select(c => c.CveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAdditions_AreCapped()
|
||||
{
|
||||
var rows = new List<CveExposureEntry> { Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0) };
|
||||
rows.AddRange(Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxKevAdditions + 10)
|
||||
.Select(i => Row($"CVE-KEV-{i:D3}", $"k{i}", "High", 7.5, kev: true)));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(1 + VulnerabilitySummaryAggregator.MaxKevAdditions, r.TopCves.Count);
|
||||
Assert.Equal("CVE-WIDE", r.TopCves[0].CveId);
|
||||
Assert.All(r.TopCves.Skip(1), c => Assert.True(c.IsKev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAffectedSessions_CountsDistinctSessionsWithAKevCve()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-K1", "s1", "High", 8.8, kev: true),
|
||||
Row("CVE-K2", "s1", "High", 8.1, kev: true), // same session, counted once
|
||||
Row("CVE-K2", "s2", "High", 8.1, kev: true),
|
||||
Row("CVE-N", "s3", "High", 7.0),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.KevAffectedSessions);
|
||||
Assert.Equal(3, r.TotalAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_FoldsAProductAcrossTitlesAndCves()
|
||||
{
|
||||
const string zip = "7-zip:7-zip";
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-B", "s1", "LOW", 3.3, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-A", "s2", "HIGH", 7.8, software: "7-Zip 25.01 (x64)", product: zip),
|
||||
Row("CVE-A", "s3", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-T", "s4", "MEDIUM", 5.0, software: "Microsoft Teams", product: "microsoft:teams"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var p = r.TopSoftware[0];
|
||||
Assert.Equal(zip, p.Product);
|
||||
Assert.Equal("7-Zip", p.Name);
|
||||
Assert.Equal(3, p.AffectedSessions);
|
||||
Assert.Equal(2, p.CveCount);
|
||||
Assert.Equal(0, p.KevCount);
|
||||
Assert.Equal("HIGH", p.CvssSeverity);
|
||||
Assert.Equal(7.8, p.CvssScore);
|
||||
Assert.Equal(new[] { "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)" }, p.Titles); // most sessions first
|
||||
var a = p.Cves.Single(c => c.CveId == "CVE-A");
|
||||
Assert.Equal(3, a.AffectedSessions);
|
||||
Assert.Equal(1, p.Cves.Single(c => c.CveId == "CVE-B").AffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_RowWithoutProductBorrowsItFromTheSameTitle_elseUsesTheTitle()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "High", 7.8, software: "7-Zip 24.08 (x64 edition)", product: "7-zip:7-zip"),
|
||||
Row("CVE-A", "s2", "High", 7.8, software: "7-zip 24.08 (X64 Edition)"), // legacy row, same title
|
||||
Row("CVE-C", "s3", "High", 7.1, software: "Contoso Tool 1.0"), // legacy row, title never seen with a product
|
||||
Row("CVE-D", "s4", "High", 7.1, software: ""), // neither: left out of the product view
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var zip = r.TopSoftware.Single(s => s.Product == "7-zip:7-zip");
|
||||
Assert.Equal(2, zip.AffectedSessions);
|
||||
var contoso = r.TopSoftware.Single(s => s.Product == "Contoso Tool 1.0");
|
||||
Assert.Equal("Contoso Tool", contoso.Name);
|
||||
Assert.Equal(3, r.DistinctCves); // the row without a title still counts as a CVE
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_CarriesTheMostUrgentCvesFirst_cappedWithTheFullCount()
|
||||
{
|
||||
const string chrome = "google:chrome";
|
||||
var act = Row("CVE-ACT", "s1", "High", 8.1, kev: true, software: "Google Chrome", product: chrome); act.Priority = "act";
|
||||
var attendEpss = Row("CVE-ATT-E", "s1", "Medium", 6.5, software: "Google Chrome", product: chrome); attendEpss.Priority = "attend"; attendEpss.EpssScore = 0.4;
|
||||
var attendCvss = Row("CVE-ATT-C", "s1", "Critical", 9.6, software: "Google Chrome", product: chrome); attendCvss.Priority = "attend"; attendCvss.EpssScore = 0.01;
|
||||
var rows = new List<CveExposureEntry> { act, attendEpss, attendCvss };
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var track = Row($"CVE-TRK-{i}", "s1", "High", 7.0 + i / 10.0, software: "Google Chrome", product: chrome);
|
||||
track.Priority = "track";
|
||||
rows.Add(track);
|
||||
}
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
var p = Assert.Single(r.TopSoftware);
|
||||
Assert.Equal(8, p.CveCount);
|
||||
Assert.Equal(1, p.KevCount);
|
||||
Assert.Equal("act", p.Priority);
|
||||
Assert.Equal("Critical", p.CvssSeverity); // worst band, with the score of that CVE
|
||||
Assert.Equal(9.6, p.CvssScore);
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxCvesPerSoftware, p.Cves.Count);
|
||||
Assert.Equal(new[] { "CVE-ACT", "CVE-ATT-E", "CVE-ATT-C" }, p.Cves.Select(c => c.CveId)); // act, then EPSS before CVSS
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_IsCappedIndependentlyOfTopN_plusCappedKevProducts()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>();
|
||||
for (var p = 0; p < VulnerabilitySummaryAggregator.MaxSoftware + 5; p++)
|
||||
for (var s = 0; s <= p; s++) // product p has p + 1 sessions
|
||||
rows.Add(Row($"CVE-P{p}", $"p{p}-s{s}", "High", 7.0, software: $"Product {p}", product: $"acme:p{p}"));
|
||||
for (var k = 0; k < VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2; k++)
|
||||
rows.Add(Row($"CVE-K{k}", $"k{k}", "High", 7.8, kev: true, software: $"Kev Product {k}", product: $"acme:k{k}"));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 100, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(
|
||||
VulnerabilitySummaryAggregator.MaxSoftware + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions,
|
||||
r.TopSoftware.Count);
|
||||
Assert.Equal("acme:p24", r.TopSoftware[0].Product); // most sessions first
|
||||
Assert.All(r.TopSoftware.Skip(VulnerabilitySummaryAggregator.MaxSoftware), s => Assert.True(s.KevCount > 0));
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxSoftware + 5 + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_AppendsProductsWithAKevCveBelowTheCut()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-M", "s3", "High", 9.0, software: "Mid App", product: "acme:mid"),
|
||||
Row("CVE-K", "s4", "High", 7.8, kev: true, software: "WinRAR 6.22 (64-bit)", product: "rarlab:winrar"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide", "rarlab:winrar" }, r.TopSoftware.Select(s => s.Product));
|
||||
Assert.Equal("WinRAR", r.TopSoftware[1].Name);
|
||||
Assert.Equal(3, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandCves_AddEachBandsTopEntriesOutsideTheList_onlyWhenAsked()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-HIGH-2", "s1", "High", 7.5), Row("CVE-HIGH-2", "s2", "High", 7.5),
|
||||
Row("CVE-ATT-1", "s1", "Critical", 9.8), Row("CVE-ATT-1", "s4", "Critical", 9.8),
|
||||
Row("CVE-ATT-2", "s5", "Critical", 9.1),
|
||||
Row("CVE-ATT-3", "s6", "Medium", 5.0),
|
||||
Row("CVE-LOW", "s7", "Low", 2.0),
|
||||
};
|
||||
foreach (var r in rows)
|
||||
r.Priority = r.CveId.StartsWith("CVE-ATT", StringComparison.Ordinal) ? "attend" : "track";
|
||||
|
||||
var plain = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
Assert.Null(plain.BandCves);
|
||||
Assert.Null(plain.BandSoftware);
|
||||
|
||||
var r2 = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 2);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE" }, r2.TopCves.Select(c => c.CveId));
|
||||
// critical: ATT-1, ATT-2 · high: WIDE (listed), HIGH-2 · medium: ATT-3 · low: LOW · attend: ATT-1, ATT-2 · track: WIDE, HIGH-2
|
||||
Assert.Equal(new[] { "CVE-ATT-1", "CVE-HIGH-2", "CVE-ATT-2", "CVE-ATT-3", "CVE-LOW" }, r2.BandCves!.Select(c => c.CveId));
|
||||
Assert.All(r2.BandCves!, c => Assert.NotEmpty(c.SampleSoftware));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandSoftware_BandsProductsByTheirWorstValues_outsideTopSoftware()
|
||||
{
|
||||
var wide1 = Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"); wide1.Priority = "track";
|
||||
var wide2 = Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"); wide2.Priority = "track";
|
||||
var crit = Row("CVE-C", "s3", "Critical", 9.6, software: "Edge Tool", product: "acme:edge"); crit.Priority = "attend";
|
||||
var low = Row("CVE-L", "s4", "Low", 2.1, software: "Small Tool", product: "acme:small"); low.Priority = "track";
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(
|
||||
new[] { wide1, wide2, crit, low }, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 1);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide" }, r.TopSoftware.Select(s => s.Product));
|
||||
// critical + attend → edge; low → small; high + track are already held by wide.
|
||||
Assert.Equal(new[] { "acme:edge", "acme:small" }, r.BandSoftware!.Select(s => s.Product));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBand_IsClamped()
|
||||
{
|
||||
var rows = Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxPerBand + 10)
|
||||
.Select(i => Row($"CVE-{i:D3}", $"s{i}", "Medium", 5.0))
|
||||
.ToList();
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 999);
|
||||
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxPerBand - 1, r.BandCves!.Count); // the band's first entry is already listed
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("7-Zip", "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)", "7-Zip 26.00 (x64 edition)")]
|
||||
[InlineData("Cisco Secure Client", "Cisco Secure Client - AnyConnect VPN ", "Cisco Secure Client - Cloud Management")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "MSTeams", "Teams Machine-Wide Installer")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "Microsoft 365 Apps")] // one shared word is not a name
|
||||
[InlineData("WinRAR", "WinRAR 6.22 (64-bit)")]
|
||||
[InlineData("Mozilla Firefox", "Mozilla Firefox (x64 en-US)")]
|
||||
[InlineData("Google Chrome", "Google Chrome 127.0.6533.74")]
|
||||
[InlineData("Microsoft Office 2016", "Microsoft Office 2016")] // a bare year or major version stays
|
||||
[InlineData("PowerShell 7-x64", "PowerShell 7-x64")]
|
||||
[InlineData("Contoso Agent", "Contoso Agent v2.4.1 x64")]
|
||||
[InlineData("1.2.3", "1.2.3")] // never stripped to nothing
|
||||
public void DisplayName_UsesSharedWordsOrTheMostCommonTitle_withoutVersionAndArchitecture(string expected, params string[] titles)
|
||||
=> Assert.Equal(expected, VulnerabilitySummaryAggregator.DisplayName(titles));
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_OfNoTitlesIsEmpty()
|
||||
=> Assert.Equal("", VulnerabilitySummaryAggregator.DisplayName(Array.Empty<string>()));
|
||||
}
|
||||
|
||||
@@ -655,12 +655,12 @@ public class VulnerabilityWireParityTests
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary -------------------------
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous);
|
||||
// it only gained the IApiResponse marker. The anonymous side here replicates the record's
|
||||
// established wire shape to pin key names, order and the TotalAffectedTenants omission.
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous).
|
||||
// The anonymous side pins the wire shape: key names, order and the TotalAffectedTenants /
|
||||
// EpssScore omission. D-307 added distinctSoftware, kevAffectedSessions and topSoftware.
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
public void CveExposureSummary_pins_the_cross_tenant_wire_shape()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
|
||||
@@ -686,10 +686,32 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 11,
|
||||
totalAffectedTenants = (int?)3,
|
||||
distinctCves = 3,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 1,
|
||||
kevAffectedSessions = 7,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
product = "7-zip:7-zip",
|
||||
name = "7-Zip",
|
||||
affectedSessions = 7,
|
||||
cveCount = 2,
|
||||
kevCount = 1,
|
||||
cvssSeverity = "critical",
|
||||
cvssScore = 9.8,
|
||||
priority = "act",
|
||||
titles = new[] { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
cves = new object[]
|
||||
{
|
||||
new { cveId = "CVE-2026-12345", cvssSeverity = "critical", cvssScore = 9.8, isKev = true, epssScore = (double?)0.94, priority = "act", affectedSessions = 7 },
|
||||
new { cveId = "CVE-2026-23456", cvssSeverity = "low", cvssScore = 3.3, isKev = false, epssScore = (double?)null, priority = "track", affectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -698,10 +720,88 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 11,
|
||||
TotalAffectedTenants = 3,
|
||||
DistinctCves = 3,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 1,
|
||||
KevAffectedSessions = 7,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = new List<TopSoftware>
|
||||
{
|
||||
new TopSoftware
|
||||
{
|
||||
Product = "7-zip:7-zip",
|
||||
Name = "7-Zip",
|
||||
AffectedSessions = 7,
|
||||
CveCount = 2,
|
||||
KevCount = 1,
|
||||
CvssSeverity = "critical",
|
||||
CvssScore = 9.8,
|
||||
Priority = "act",
|
||||
Titles = new List<string> { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
Cves = new List<SoftwareCve>
|
||||
{
|
||||
new SoftwareCve { CveId = "CVE-2026-12345", CvssSeverity = "critical", CvssScore = 9.8, IsKev = true, EpssScore = 0.94, Priority = "act", AffectedSessions = 7 },
|
||||
new SoftwareCve { CveId = "CVE-2026-23456", CvssSeverity = "low", CvssScore = 3.3, IsKev = false, EpssScore = null, Priority = "track", AffectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_carries_the_band_lists_after_topSoftware_when_requested()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 1, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 1, Track = 1 };
|
||||
var wide = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-11111", CvssSeverity = "HIGH", CvssScore = 7.5, IsKev = false, EpssScore = 0.01,
|
||||
Priority = "track", AffectedSessions = 40, SampleSoftware = new List<string> { "Contoso Agent 2.1" },
|
||||
};
|
||||
var attend = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-22222", CvssSeverity = "CRITICAL", CvssScore = 9.6, IsKev = false, EpssScore = null,
|
||||
Priority = "attend", AffectedSessions = 2, SampleSoftware = new List<string> { "Contoso Viewer 1.0" },
|
||||
};
|
||||
var topCves = new List<TopCve> { wide };
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
var bandCves = new List<TopCve> { attend };
|
||||
var bandSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
windowDays = 30,
|
||||
totalAffectedSessions = 42,
|
||||
distinctCves = 2,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
bandCves,
|
||||
bandSoftware,
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
{
|
||||
WindowDays = 30,
|
||||
TotalAffectedSessions = 42,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 2,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
@@ -712,6 +812,7 @@ public class VulnerabilityWireParityTests
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -720,10 +821,13 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 0,
|
||||
totalAffectedTenants = (int?)null,
|
||||
distinctCves = 0,
|
||||
distinctSoftware = 0,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
truncated = true,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -732,10 +836,13 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 0,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 0,
|
||||
DistinctSoftware = 0,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
Truncated = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Windows Update during OOBE — pins the firing behaviour of the built-in analyze rules
|
||||
/// that surface update activity captured by the agent's WindowsUpdateTracker:
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED during enrollment.
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED to install or download during
|
||||
/// enrollment (wuPhase install/download — never an update-scan failure).
|
||||
/// - ANALYZE-DEV-005 (info): a quality/cumulative update INSTALLED during enrollment.
|
||||
/// - ANALYZE-DEV-006 (info): the OS build changed across a mid-enrollment reboot —
|
||||
/// deterministic corroboration that works even when the WU channel showed nothing
|
||||
@@ -51,6 +52,62 @@ public class WindowsUpdateRuleTests
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", AsString(symbol["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_fires_on_a_failed_download()
|
||||
{
|
||||
// EventID 31 from the Operational channel (D-310): the update never finished downloading.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x80248007", "WU_E_DS_NODATA",
|
||||
wuEventId: 31, wuPhase: "download"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x80248007", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_on_an_update_scan_failure()
|
||||
{
|
||||
// EventID 25 names no update: a failed scan (often a transient network or proxy error) is
|
||||
// not a failed update and must not raise a high-severity finding.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_takes_its_evidence_from_the_install_failure_not_the_scan_failure()
|
||||
{
|
||||
// The phase filter applies to every evidence condition: the explanation must name the
|
||||
// failed update and its error, never the error of an earlier scan.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x800F0922", "CBS_E_INSTALLERS_FAILED"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x800F0922", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", AsString(AsDict(result.MatchedConditions["wu_hresult_symbol"])["value"]));
|
||||
Assert.Equal("2026-09 Cumulative Update for Windows 11 (KB5099999)",
|
||||
AsString(AsDict(result.MatchedConditions["wu_update_title"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_without_a_failed_update()
|
||||
{
|
||||
@@ -217,11 +274,12 @@ public class WindowsUpdateRuleTests
|
||||
["wuClientCensus"] = "21=1,25=3",
|
||||
["updateOrchestratorCensus"] = "200=2",
|
||||
["lookbackMinutes"] = 60,
|
||||
["targetedEventIds"] = "19,20,43,44",
|
||||
["targetedEventIds"] = "19,20,25,26,31,41,43,44",
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateFailed(string title, string hresult, string hresultSymbol) => new()
|
||||
private static EnrollmentEvent WindowsUpdateFailed(
|
||||
string title, string hresult, string hresultSymbol, int wuEventId = 20, string wuPhase = "install") => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
@@ -231,7 +289,10 @@ public class WindowsUpdateRuleTests
|
||||
Sequence = 42,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 20,
|
||||
["wuEventId"] = wuEventId,
|
||||
["wuChannel"] = wuEventId == 20 ? "system" : "operational",
|
||||
["wuPhase"] = wuPhase,
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["hresult"] = hresult,
|
||||
@@ -240,6 +301,25 @@ public class WindowsUpdateRuleTests
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateScanFailed(string hresult, string hresultSymbol) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "windows_update_failed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 41,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 25,
|
||||
["wuChannel"] = "operational",
|
||||
["wuPhase"] = "scan",
|
||||
["hresult"] = hresult,
|
||||
["hresultSymbol"] = hresultSymbol,
|
||||
["backfilled"] = false,
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateSucceeded(string title) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
@@ -251,6 +331,9 @@ public class WindowsUpdateRuleTests
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 19,
|
||||
["wuChannel"] = "system",
|
||||
["wuPhase"] = "installed",
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["backfilled"] = false,
|
||||
|
||||
+6
-2
@@ -498,7 +498,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
public async Task<bool> RenameTenantGroupAsync(string groupId, string name)
|
||||
public async Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(groupId))
|
||||
return false;
|
||||
@@ -510,7 +510,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
var entity = result.Value;
|
||||
if (entity == null) return false;
|
||||
|
||||
entity.Name = name?.Trim() ?? string.Empty;
|
||||
if (name != null)
|
||||
entity.Name = name.Trim();
|
||||
if (customerLabel != null)
|
||||
entity.CustomerLabel = customerLabel.Trim();
|
||||
await _tenantGroupsTableClient.UpdateEntityAsync(entity, ETag.All);
|
||||
return true;
|
||||
}
|
||||
@@ -1022,6 +1025,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
group.CreatedBy = entity.CreatedBy;
|
||||
group.CreatedAt = entity.CreatedDate ?? default;
|
||||
group.OwnerTenantId = string.IsNullOrEmpty(entity.OwnerTenantId) ? null : entity.OwnerTenantId;
|
||||
group.CustomerLabel = string.IsNullOrEmpty(entity.CustomerLabel) ? null : entity.CustomerLabel;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -949,7 +949,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
|
||||
EnrollmentSummaryBrandingImageUrl = entity.GetString("EnrollmentSummaryBrandingImageUrl"),
|
||||
EnrollmentSummaryLaunchRetrySeconds = entity.GetInt32("EnrollmentSummaryLaunchRetrySeconds"),
|
||||
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? 30,
|
||||
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? HelloWaitTimeout.DefaultSeconds,
|
||||
AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"),
|
||||
SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true,
|
||||
EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"),
|
||||
|
||||
+58
-13
@@ -23,8 +23,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
/// repository directly) so the delegated-scope cache is invalidated in lockstep.
|
||||
///
|
||||
/// Audit: access-affecting mutations are logged under the <b>affected managed tenant(s)</b>' trail (so the
|
||||
/// customer sees "who can read my tenant" / "access removed"). Group create/rename carry no tenant context
|
||||
/// and are not customer-visible access changes — they are logged operationally only (no AuditLogs partition).
|
||||
/// customer sees "who can read my tenant" / "access removed"); the group appears there under its customer-facing
|
||||
/// name (<see cref="DelegationSelfService.CustomerNameOf"/>), never its internal name. Group create/rename/label
|
||||
/// carry no access change — they are logged operationally only (no AuditLogs partition).
|
||||
/// </summary>
|
||||
public class TenantGroupManagementFunction
|
||||
{
|
||||
@@ -100,8 +101,11 @@ public class TenantGroupManagementFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/global/tenant-groups/{groupId} — rename a group. GlobalAdminOnly.
|
||||
/// Body: { "name": "..." }. A rename has no scope effect.
|
||||
/// PATCH /api/global/tenant-groups/{groupId} — rename a group and/or set the customer label of an operator
|
||||
/// group. GlobalAdminOnly. Body: { "name"?: "...", "customerLabel"?: "..." } (only the fields to change;
|
||||
/// an empty label clears it). Neither has a scope effect; the label only changes how the access is named to
|
||||
/// the member tenants (<see cref="DelegationSelfService.CustomerNameOf"/>), so it is logged operationally
|
||||
/// like a rename, not audited as an access change.
|
||||
/// </summary>
|
||||
[Function("UpdateTenantGroup")]
|
||||
[Authorize]
|
||||
@@ -114,14 +118,55 @@ public class TenantGroupManagementFunction
|
||||
var read = await req.ReadAsync<UpdateTenantGroupRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Name))
|
||||
return await Bad(req, "name is required");
|
||||
var validationError = ValidateUpdateRequest(body);
|
||||
if (validationError != null)
|
||||
return await Bad(req, validationError);
|
||||
|
||||
if (!await _delegatedAdminService.RenameGroupAsync(groupId, body.Name))
|
||||
return await NotFound(req);
|
||||
_logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn);
|
||||
return await UpdateGroupCoreAsync(groupId, body, currentUpn) switch
|
||||
{
|
||||
GroupUpdateOutcome.NotFound => await NotFound(req),
|
||||
GroupUpdateOutcome.LabelOnOwnedGroup => await req.ConflictAsync(
|
||||
"A self-service group has no customer label: its customers see the managing organization's name."),
|
||||
_ => await req.OkAsync(new MessageResponse { Message = "Group updated" }),
|
||||
};
|
||||
}
|
||||
|
||||
return await req.OkAsync(new MessageResponse { Message = "Group updated" });
|
||||
/// <summary>Outcome of <see cref="UpdateGroupCoreAsync"/>.</summary>
|
||||
internal enum GroupUpdateOutcome { Updated, NotFound, LabelOnOwnedGroup }
|
||||
|
||||
/// <summary>Shape check of a PATCH body (400 message, or null when valid): at least one field, no blank name, a bounded label.</summary>
|
||||
internal static string? ValidateUpdateRequest(UpdateTenantGroupRequest body)
|
||||
{
|
||||
if (body.Name == null && body.CustomerLabel == null)
|
||||
return "name or customerLabel is required";
|
||||
if (body.Name != null && string.IsNullOrWhiteSpace(body.Name))
|
||||
return "name must not be empty";
|
||||
if (body.CustomerLabel != null && body.CustomerLabel.Trim().Length > DelegationSelfService.CustomerLabelMaxLength)
|
||||
return $"customerLabel must be at most {DelegationSelfService.CustomerLabelMaxLength} characters";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Testable core of the group update (body already validated). A customer label is refused on an OWNED
|
||||
/// (self-service) group — its customers see the managing tenant's name, so a label there would be dead data.
|
||||
/// </summary>
|
||||
internal async Task<GroupUpdateOutcome> UpdateGroupCoreAsync(string groupId, UpdateTenantGroupRequest body, string? currentUpn)
|
||||
{
|
||||
if (body.CustomerLabel != null)
|
||||
{
|
||||
var group = await _delegatedAdminService.GetGroupAsync(groupId);
|
||||
if (group == null)
|
||||
return GroupUpdateOutcome.NotFound;
|
||||
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
|
||||
return GroupUpdateOutcome.LabelOnOwnedGroup;
|
||||
}
|
||||
|
||||
if (!await _delegatedAdminService.UpdateGroupAsync(groupId, body.Name, body.CustomerLabel))
|
||||
return GroupUpdateOutcome.NotFound;
|
||||
|
||||
_logger.LogInformation("Tenant group updated: {GroupId} name='{Name}' customerLabel='{CustomerLabel}' by {By}",
|
||||
groupId, body.Name, body.CustomerLabel, currentUpn);
|
||||
return GroupUpdateOutcome.Updated;
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly.</summary>
|
||||
@@ -173,7 +218,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Reason", "group-deleted" },
|
||||
{ "AssigneeCount", group.AssigneeCount.ToString() },
|
||||
@@ -335,7 +380,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "CREATE", upn, currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Role", role },
|
||||
{ "Reason", "group-assigned" },
|
||||
@@ -384,7 +429,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", normalizedUpn, currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Reason", "group-unassigned" },
|
||||
});
|
||||
|
||||
+353
-56
@@ -7,7 +7,7 @@ using AutopilotMonitor.Shared.Models.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve) live in
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve, TopSoftware) live in
|
||||
// AutopilotMonitor.Shared.Models/VulnerabilityApiModels.cs so the manifest export sees them.
|
||||
|
||||
/// <summary>
|
||||
@@ -17,6 +17,38 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
/// </summary>
|
||||
public static class VulnerabilitySummaryAggregator
|
||||
{
|
||||
// The caps keep the response under the MCP tool's 50k-character result cap at topN = 100
|
||||
// (a TopCve is ~210 characters, a TopSoftware entry ~800); the MCP passes it through verbatim.
|
||||
|
||||
/// <summary>
|
||||
/// Cap on the KEV CVEs appended below the topN cut. Known-exploited CVEs are "patch first"
|
||||
/// whatever their device count, so a list ranked by devices must not drop them.
|
||||
/// </summary>
|
||||
public const int MaxKevAdditions = 25;
|
||||
|
||||
/// <summary>Products ranked by devices, before the KEV additions (the list is min(topN, this) long).</summary>
|
||||
public const int MaxSoftware = 20;
|
||||
|
||||
/// <summary>Cap on the products with a KEV CVE appended below the product cut.</summary>
|
||||
public const int MaxKevSoftwareAdditions = 10;
|
||||
|
||||
/// <summary>CVEs carried per product entry; <see cref="TopSoftware.CveCount"/> keeps the full count.</summary>
|
||||
public const int MaxCvesPerSoftware = 3;
|
||||
|
||||
/// <summary>Raw titles carried per product entry.</summary>
|
||||
public const int MaxTitlesPerSoftware = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the per-band representatives (<see cref="CveExposureSummary.BandCves"/>).
|
||||
/// Only the portal asks for them; the MCP tool never does, so its response stays within its cap.
|
||||
/// </summary>
|
||||
public const int MaxPerBand = 25;
|
||||
|
||||
private static readonly HashSet<string> ArchitectureTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"x64", "x86", "amd64", "arm64", "64-bit", "32-bit",
|
||||
};
|
||||
|
||||
private static int SeverityRank(string? severity) => severity?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 4,
|
||||
@@ -26,15 +58,39 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static string SeverityKey(int rank) => rank switch
|
||||
{
|
||||
4 => "critical",
|
||||
3 => "high",
|
||||
2 => "medium",
|
||||
1 => "low",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string PriorityKey(string? priority) => CvePriority.Rank(priority) switch
|
||||
{
|
||||
3 => CvePriority.Act,
|
||||
2 => CvePriority.Attend,
|
||||
1 => CvePriority.Track,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <param name="perBand">
|
||||
/// When above 0, the summary also carries <see cref="CveExposureSummary.BandCves"/> and
|
||||
/// <see cref="CveExposureSummary.BandSoftware"/>: per severity and priority band its top
|
||||
/// entries outside the lists, so a band filter never comes up empty while the band has entries.
|
||||
/// </param>
|
||||
public static CveExposureSummary Aggregate(
|
||||
IReadOnlyList<CveExposureEntry> rows,
|
||||
bool truncated,
|
||||
int topN,
|
||||
int days,
|
||||
bool crossTenant,
|
||||
DateTime nowUtc)
|
||||
DateTime nowUtc,
|
||||
int perBand = 0)
|
||||
{
|
||||
if (topN < 1) topN = 1;
|
||||
perBand = Math.Clamp(perBand, 0, MaxPerBand);
|
||||
var windowDays = Math.Clamp(days, 1, 365);
|
||||
var cutoff = nowUtc.AddDays(-windowDays);
|
||||
|
||||
@@ -44,73 +100,62 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var inWindow = rows
|
||||
.Where(r => !r.DetectedAt.HasValue || r.DetectedAt.Value >= cutoff)
|
||||
.ToList();
|
||||
var withCve = inWindow.Where(r => !string.IsNullOrEmpty(r.CveId)).ToList();
|
||||
|
||||
var groups = inWindow
|
||||
var cves = withCve
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key))
|
||||
.Select(g =>
|
||||
{
|
||||
var topSeverityRow = g.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new
|
||||
{
|
||||
CveId = g.Key,
|
||||
Severity = topSeverityRow.CvssSeverity,
|
||||
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore = g.Max(r => r.CvssScore),
|
||||
IsKev = g.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore = g.Max(r => r.EpssScore),
|
||||
Priority = g.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions = g.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
SampleSoftware = g.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
})
|
||||
.Select(g => FoldCve(g.Key, g.ToList()))
|
||||
.ToList();
|
||||
|
||||
var topCves = groups
|
||||
var rankedCves = cves
|
||||
.OrderByDescending(c => c.AffectedSessions)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(topN)
|
||||
.Select(c => new TopCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.SampleSoftware,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var listedCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions);
|
||||
|
||||
var software = FoldSoftware(withCve);
|
||||
var rankedSoftware = software
|
||||
.OrderByDescending(s => s.AffectedSessions)
|
||||
.ThenByDescending(s => CvePriority.Rank(s.Priority))
|
||||
.ThenByDescending(s => s.CvssScore)
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(s => s.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var topSoftware = WithKevAdditions(
|
||||
rankedSoftware, Math.Min(topN, MaxSoftware), s => s.KevCount > 0, MaxKevSoftwareAdditions);
|
||||
|
||||
// A product is banded by its worst values, the only ones it carries.
|
||||
List<TopCve>? bandCves = null;
|
||||
List<TopSoftware>? bandSoftware = null;
|
||||
if (perBand > 0)
|
||||
{
|
||||
bandCves = BandRepresentatives(
|
||||
rankedCves, listedCves, perBand,
|
||||
c => SeverityKey(c.SeverityRank), c => PriorityKey(c.Priority))
|
||||
.Select(ToTopCve)
|
||||
.ToList();
|
||||
bandSoftware = BandRepresentatives(
|
||||
rankedSoftware, topSoftware, perBand,
|
||||
s => SeverityKey(SeverityRank(s.CvssSeverity)), s => PriorityKey(s.Priority));
|
||||
}
|
||||
|
||||
var breakdown = new SeverityBreakdown
|
||||
{
|
||||
Critical = groups.Count(c => c.SeverityRank == 4),
|
||||
High = groups.Count(c => c.SeverityRank == 3),
|
||||
Medium = groups.Count(c => c.SeverityRank == 2),
|
||||
Low = groups.Count(c => c.SeverityRank == 1),
|
||||
Critical = cves.Count(c => c.SeverityRank == 4),
|
||||
High = cves.Count(c => c.SeverityRank == 3),
|
||||
Medium = cves.Count(c => c.SeverityRank == 2),
|
||||
Low = cves.Count(c => c.SeverityRank == 1),
|
||||
};
|
||||
|
||||
var priorities = new PriorityBreakdown
|
||||
{
|
||||
Act = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
Act = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
};
|
||||
|
||||
var totalAffectedSessions = inWindow.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
int? totalAffectedTenants = crossTenant
|
||||
? inWindow.Select(r => r.TenantId)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
@@ -120,15 +165,267 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
return new CveExposureSummary
|
||||
{
|
||||
WindowDays = windowDays,
|
||||
TotalAffectedSessions = totalAffectedSessions,
|
||||
TotalAffectedSessions = DistinctSessions(inWindow),
|
||||
TotalAffectedTenants = totalAffectedTenants,
|
||||
DistinctCves = groups.Count,
|
||||
KevCves = groups.Count(c => c.IsKev),
|
||||
DistinctCves = cves.Count,
|
||||
DistinctSoftware = software.Count,
|
||||
KevCves = cves.Count(c => c.IsKev),
|
||||
KevAffectedSessions = DistinctSessions(withCve.Where(r => r.IsKev)),
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopCves = listedCves.Select(ToTopCve).ToList(),
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = truncated,
|
||||
};
|
||||
}
|
||||
|
||||
private static TopCve ToTopCve(CveFold c) => new()
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.Rows.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// For every band of every banding (severity, priority): the band's first
|
||||
/// <paramref name="perBand"/> entries of the ranked list, minus the entries already
|
||||
/// <paramref name="listed"/>. Entries without a band add nothing; the ranked order is kept.
|
||||
/// </summary>
|
||||
internal static List<T> BandRepresentatives<T>(
|
||||
IReadOnlyList<T> ranked,
|
||||
IEnumerable<T> listed,
|
||||
int perBand,
|
||||
params Func<T, string>[] bandings) where T : class
|
||||
{
|
||||
var alreadyListed = new HashSet<T>(listed, ReferenceEqualityComparer.Instance);
|
||||
var picked = new SortedSet<int>();
|
||||
foreach (var bandOf in bandings)
|
||||
{
|
||||
var taken = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < ranked.Count; i++)
|
||||
{
|
||||
var band = bandOf(ranked[i]);
|
||||
if (string.IsNullOrEmpty(band)) continue;
|
||||
taken.TryGetValue(band, out var count);
|
||||
if (count >= perBand) continue;
|
||||
taken[band] = count + 1;
|
||||
if (!alreadyListed.Contains(ranked[i])) picked.Add(i);
|
||||
}
|
||||
}
|
||||
return picked.Select(i => ranked[i]).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="topN"/> entries of a ranked list plus the KEV entries below the
|
||||
/// cut (at most <paramref name="maxAdditions"/>). The input order is kept, so a list ranked by
|
||||
/// affected sessions stays ranked.
|
||||
/// </summary>
|
||||
internal static List<T> WithKevAdditions<T>(IReadOnlyList<T> ranked, int topN, Func<T, bool> isKev, int maxAdditions)
|
||||
=> ranked.Take(topN)
|
||||
.Concat(ranked.Skip(topN).Where(isKev).Take(maxAdditions))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Display name for a product from its detected titles, most sessions first: the leading
|
||||
/// words every title shares when that is at least two words, else the most common title.
|
||||
/// A trailing version or architecture suffix is dropped ("7-Zip 24.08 (x64 edition)" →
|
||||
/// "7-Zip"); a name that would end up empty keeps its title.
|
||||
/// </summary>
|
||||
internal static string DisplayName(IReadOnlyList<string> titles)
|
||||
{
|
||||
var cleaned = titles.Select(t => t?.Trim() ?? string.Empty).Where(t => t.Length > 0).ToList();
|
||||
if (cleaned.Count == 0) return string.Empty;
|
||||
|
||||
var words = cleaned.Select(t => t.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToList();
|
||||
var shared = 0;
|
||||
while (shared < words[0].Length
|
||||
&& words.All(w => w.Length > shared && string.Equals(w[shared], words[0][shared], StringComparison.OrdinalIgnoreCase)))
|
||||
shared++;
|
||||
|
||||
var prefix = TrimTrailingSeparators(words[0].Take(shared).ToList());
|
||||
var baseName = cleaned.Count > 1 && prefix.Count >= 2 ? string.Join(" ", prefix) : cleaned[0];
|
||||
var stripped = StripVersionSuffix(baseName);
|
||||
return stripped.Length > 0 ? stripped : baseName;
|
||||
}
|
||||
|
||||
private static string StripVersionSuffix(string name)
|
||||
{
|
||||
var current = name.Trim();
|
||||
while (true)
|
||||
{
|
||||
var before = current;
|
||||
|
||||
// A trailing parenthetical: "(x64 edition)", "(64-bit)", "(x64 en-US)".
|
||||
if (current.EndsWith(")", StringComparison.Ordinal))
|
||||
{
|
||||
var open = current.LastIndexOf('(');
|
||||
if (open > 0) current = current.Substring(0, open).TrimEnd();
|
||||
}
|
||||
|
||||
var tokens = current.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
if (tokens.Count > 1 && (IsVersionToken(tokens[^1]) || ArchitectureTokens.Contains(tokens[^1])))
|
||||
tokens.RemoveAt(tokens.Count - 1);
|
||||
current = string.Join(" ", TrimTrailingSeparators(tokens));
|
||||
|
||||
if (current == before) return current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"24.08", "v1.2.3", "127.0.6533.74" — dotted digits, optional leading v. A bare integer ("2016", "7") is part of the name.</summary>
|
||||
private static bool IsVersionToken(string token)
|
||||
{
|
||||
var s = token.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? token.Substring(1) : token;
|
||||
if (s.Length < 3 || !char.IsDigit(s[0]) || !char.IsDigit(s[^1])) return false;
|
||||
var dots = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch == '.') dots++;
|
||||
else if (!char.IsDigit(ch)) return false;
|
||||
}
|
||||
return dots > 0;
|
||||
}
|
||||
|
||||
private static List<string> TrimTrailingSeparators(List<string> tokens)
|
||||
{
|
||||
var list = tokens.ToList();
|
||||
while (list.Count > 0)
|
||||
{
|
||||
var last = list[^1].TrimEnd('-', ':', ',', '|', '/', '–', '—');
|
||||
if (last.Length == 0) { list.RemoveAt(list.Count - 1); continue; }
|
||||
list[^1] = last;
|
||||
break;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static int DistinctSessions(IEnumerable<CveExposureEntry> rows)
|
||||
=> rows.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
private static CveFold FoldCve(string cveId, List<CveExposureEntry> rows)
|
||||
{
|
||||
var topSeverityRow = rows.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new CveFold(
|
||||
CveId: cveId,
|
||||
Severity: topSeverityRow.CvssSeverity,
|
||||
SeverityRank: SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore: rows.Max(r => r.CvssScore),
|
||||
IsKev: rows.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore: rows.Max(r => r.EpssScore),
|
||||
Priority: rows.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions: DistinctSessions(rows),
|
||||
Rows: rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds the rows per product. A row without a product (written before the index carried
|
||||
/// it, or a finding without a CPE) borrows the product of another row with the same title —
|
||||
/// so the transition converges as new sessions are correlated — and otherwise is keyed by
|
||||
/// its title. Rows with neither are left out of the product view.
|
||||
/// </summary>
|
||||
private static List<TopSoftware> FoldSoftware(List<CveExposureEntry> rows)
|
||||
{
|
||||
var productByTitle = rows
|
||||
.Where(r => !string.IsNullOrEmpty(r.Product) && !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.GroupBy(r => r.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(p => p.Count())
|
||||
.ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.First().Key,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string ProductKey(CveExposureEntry r)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(r.Product)) return r.Product;
|
||||
var title = r.SoftwareName?.Trim() ?? string.Empty;
|
||||
return title.Length > 0 && productByTitle.TryGetValue(title, out var product) ? product : title;
|
||||
}
|
||||
|
||||
return rows
|
||||
.Select(r => (Row: r, Key: ProductKey(r)))
|
||||
.Where(x => x.Key.Length > 0)
|
||||
.GroupBy(x => x.Key, x => x.Row, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g =>
|
||||
{
|
||||
var productRows = g.ToList();
|
||||
var productCves = productRows
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(cg => FoldCve(cg.Key, cg.ToList()))
|
||||
.ToList();
|
||||
var worst = productCves
|
||||
.OrderByDescending(c => c.SeverityRank)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.First();
|
||||
var titles = productRows
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(tg => (Title: tg.Key, Sessions: DistinctSessions(tg)))
|
||||
.OrderByDescending(t => t.Sessions)
|
||||
.ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => t.Title)
|
||||
.ToList();
|
||||
|
||||
return new TopSoftware
|
||||
{
|
||||
Product = g.Key,
|
||||
Name = DisplayName(titles),
|
||||
AffectedSessions = DistinctSessions(productRows),
|
||||
CveCount = productCves.Count,
|
||||
KevCount = productCves.Count(c => c.IsKev),
|
||||
CvssSeverity = worst.Severity,
|
||||
CvssScore = worst.CvssScore,
|
||||
Priority = productCves.Select(c => c.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
Titles = titles.Take(MaxTitlesPerSoftware).ToList(),
|
||||
Cves = productCves
|
||||
.OrderByDescending(c => CvePriority.Rank(c.Priority))
|
||||
.ThenByDescending(c => c.EpssScore ?? -1)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenByDescending(c => c.AffectedSessions)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxCvesPerSoftware)
|
||||
.Select(c => new SoftwareCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One CVE folded across its rows (the whole scope, or one product's rows).</summary>
|
||||
private sealed record CveFold(
|
||||
string CveId,
|
||||
string Severity,
|
||||
int SeverityRank,
|
||||
double CvssScore,
|
||||
bool IsKev,
|
||||
double? EpssScore,
|
||||
string Priority,
|
||||
int AffectedSessions,
|
||||
List<CveExposureEntry> Rows);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20&perBand=10
|
||||
/// Fleet vulnerability summary for the caller's tenant (MemberRead).
|
||||
/// </summary>
|
||||
[Function("GetVulnerabilitySummary")]
|
||||
@@ -47,7 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&tenantId=...
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&perBand=10&tenantId=...
|
||||
/// Cross-tenant summary (Global Admin). With tenantId, scopes to one tenant.
|
||||
/// </summary>
|
||||
[Function("GetGlobalVulnerabilitySummary")]
|
||||
@@ -67,14 +67,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
|
||||
// Per-band representatives for the portal's band filters; callers that omit it
|
||||
// (the MCP tool) get the lists without them.
|
||||
var perBand = QueryParams.Int(query["perBand"], @default: 0, min: 0, max: VulnerabilitySummaryAggregator.MaxPerBand);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN);
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN}, perBand={PerBand})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN, perBand);
|
||||
|
||||
var (rows, truncated) = await _sessionRepo.ScanCveIndexAsync(tenantId, MaxScanRows);
|
||||
var summary = VulnerabilitySummaryAggregator.Aggregate(
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow);
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow, perBand);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(summary);
|
||||
|
||||
@@ -161,6 +161,13 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
|
||||
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
|
||||
|
||||
// Only a CHANGED value is checked (same rule as the retention cap below): a stored value
|
||||
// written before this rule existed must not block unrelated saves or backup reverts.
|
||||
if (candidate.HelloWaitTimeoutSeconds != existing.HelloWaitTimeoutSeconds
|
||||
&& (candidate.HelloWaitTimeoutSeconds < HelloWaitTimeout.MinSeconds
|
||||
|| candidate.HelloWaitTimeoutSeconds > HelloWaitTimeout.MaxSeconds))
|
||||
return $"Hello wait timeout must be between {HelloWaitTimeout.MinSeconds} and {HelloWaitTimeout.MaxSeconds} seconds.";
|
||||
|
||||
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
return $"Invalid contact email: {contactEmailError}";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user