mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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...c96af89214
@@ -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...5ea761ada9
+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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+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")]
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+32
-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,34 @@ 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]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swallows_sink_exceptions()
|
||||
{
|
||||
@@ -82,7 +111,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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";
|
||||
|
||||
+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
|
||||
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -729,7 +736,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))
|
||||
{
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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,7 @@ 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);
|
||||
|
||||
// V2 parity — post SessionStarted so the reducer establishes the session
|
||||
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
|
||||
|
||||
@@ -314,7 +314,8 @@ 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.
|
||||
/// <para>
|
||||
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
|
||||
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
|
||||
@@ -329,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public static void PostEnrollmentFactsObserved(
|
||||
ISignalIngressSink ingressSink,
|
||||
int helloWaitTimeoutSeconds,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
@@ -352,6 +354,16 @@ 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);
|
||||
}
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "enrollment_registry_facts_read",
|
||||
@@ -364,7 +376,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}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+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,
|
||||
});
|
||||
}
|
||||
|
||||
+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}";
|
||||
|
||||
@@ -268,9 +268,9 @@ public class DelegatedAdminService
|
||||
public Task<string> CreateGroupAsync(string name, string createdBy)
|
||||
=> _adminRepo.CreateTenantGroupAsync(name, createdBy);
|
||||
|
||||
/// <summary>Renames a group (name only — no scope effect, no invalidation needed).</summary>
|
||||
public Task<bool> RenameGroupAsync(string groupId, string name)
|
||||
=> _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name);
|
||||
/// <summary>Renames a group and/or sets its customer label (null = unchanged) — no scope effect, no invalidation needed.</summary>
|
||||
public Task<bool> UpdateGroupAsync(string groupId, string? name, string? customerLabel)
|
||||
=> _adminRepo.UpdateTenantGroupAsync(NormalizeGroupId(groupId), name, customerLabel);
|
||||
|
||||
/// <summary>Creates a tenant-OWNED (self-service) group's meta row if missing — no scope effect (a fresh group has no members).</summary>
|
||||
public Task EnsureOwnedGroupAsync(string groupId, string name, string ownerTenantId)
|
||||
@@ -521,6 +521,9 @@ public class TenantGroupEntity : ITableEntity
|
||||
|
||||
/// <summary>Meta row only: the managing tenant that owns this self-service group (see <see cref="Constants.TenantGroupIds"/>); null on operator-created groups.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
|
||||
/// <summary>Meta row only: the name a managed customer sees for an operator-created group (see <see cref="DelegationSelfService.CustomerNameOf"/>); null or empty when none is set.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -48,10 +48,13 @@ public class DelegationSelfService
|
||||
public const string SourceSelfService = "self-service";
|
||||
public const string SourceOperator = "operator";
|
||||
/// <summary>
|
||||
/// The one label a managed tenant sees for every operator-provisioned access (operator-created groups and
|
||||
/// direct grants alike). Operator groups are internal organisation — their names never reach a customer.
|
||||
/// The label a managed tenant sees for operator-provisioned access: direct grants, and operator-created groups
|
||||
/// without a <see cref="TenantGroup.CustomerLabel"/>. Operator groups are internal organisation — their names
|
||||
/// never reach a customer (D-267, D-300).
|
||||
/// </summary>
|
||||
public const string OperatorLabel = "Platform support";
|
||||
/// <summary>Longest customer label a Global Admin may set on an operator group.</summary>
|
||||
public const int CustomerLabelMaxLength = 80;
|
||||
private const string AuditGroupAccess = "DelegatedGroupAccess";
|
||||
private const string AuditInvitation = "DelegationInvitation";
|
||||
private const string AuditManagedTenant = "DelegationManagedTenant";
|
||||
@@ -382,11 +385,10 @@ public class DelegationSelfService
|
||||
.Select(r => r.AcceptedAt)
|
||||
.Max();
|
||||
}
|
||||
// An owned group carries its managing tenant's name; an operator group is internal and shows
|
||||
// only the neutral label (its assignees stay visible — the customer must know who can read it).
|
||||
// Its assignees stay visible either way — the customer must know who can read it.
|
||||
result.Add(owner != null
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), group.Name, SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, OperatorLabel, SourceOperator, group.Assignees, null, Revocable: false));
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), CustomerNameOf(group), SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, CustomerNameOf(group), SourceOperator, group.Assignees, null, Revocable: false));
|
||||
}
|
||||
|
||||
var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target))
|
||||
@@ -401,6 +403,19 @@ public class DelegationSelfService
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place that decides how a group is named to a managed customer — the "Who can read this tenant"
|
||||
/// view and every audit row written under a member tenant use it. An owned (self-service) group carries its
|
||||
/// managing tenant's name; an operator group shows the customer label a Global Admin set for it, else the
|
||||
/// neutral <see cref="OperatorLabel"/>. The operator group's own name never reaches a customer.
|
||||
/// </summary>
|
||||
public static string CustomerNameOf(TenantGroup group)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
|
||||
return group.Name;
|
||||
return string.IsNullOrWhiteSpace(group.CustomerLabel) ? OperatorLabel : group.CustomerLabel.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Decodes the ticket and loads the row. 400 for anything the link itself is wrong about.</summary>
|
||||
private async Task<DelegationResult<DelegationInvitation>> LocateAsync(string token)
|
||||
{
|
||||
|
||||
+66
-11
@@ -233,13 +233,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Explanation))
|
||||
{
|
||||
var explanation = result.Explanation.Length > 300
|
||||
? result.Explanation[..300] + "..."
|
||||
: result.Explanation;
|
||||
var explanation = RenderExplanation(result, RuleFiredExplanationMaxLength);
|
||||
if (explanation.Length > 0)
|
||||
alert.Sections.Add(new NotificationSection { Title = "Explanation", Text = explanation });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionUrl))
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open session", Url = sessionUrl });
|
||||
@@ -273,18 +269,77 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
_ => "\ud83d\udfe1"
|
||||
};
|
||||
|
||||
var explanation = r.Explanation?.Length > 200
|
||||
? r.Explanation[..200] + "..."
|
||||
: r.Explanation ?? "";
|
||||
|
||||
alert.Sections.Add(new NotificationSection
|
||||
{
|
||||
Title = $"{emoji} {r.RuleTitle}",
|
||||
Text = explanation
|
||||
Text = RenderExplanation(r, RuleSectionExplanationMaxLength)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal const int RuleFiredExplanationMaxLength = 300;
|
||||
internal const int RuleSectionExplanationMaxLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// A stored <see cref="RuleResult.Explanation"/> is the rule's template. Tokens are
|
||||
/// resolved BEFORE the cut: cutting first would split a placeholder or spend the
|
||||
/// budget on placeholder names instead of the values.
|
||||
/// </summary>
|
||||
private static string RenderExplanation(RuleResult result, int maxLength)
|
||||
=> TruncateAtBoundary(
|
||||
RuleTemplateInterpolator.InterpolateForNotification(result.Explanation, result.MatchedConditions),
|
||||
maxLength);
|
||||
|
||||
/// <summary>
|
||||
/// Shortens to at most <paramref name="maxLength"/> characters at the last line break
|
||||
/// inside the limit, else the last sentence end, else the last word. A boundary in the
|
||||
/// first half of the limit is not taken, so the cut never throws away most of the budget.
|
||||
/// A bold or code span the cut left open is closed by dropping its opening marker.
|
||||
/// </summary>
|
||||
internal static string TruncateAtBoundary(string text, int maxLength)
|
||||
{
|
||||
const string marker = " …";
|
||||
if (text.Length <= maxLength)
|
||||
return text;
|
||||
|
||||
var window = text[..(maxLength - marker.Length)];
|
||||
var floor = window.Length / 2;
|
||||
|
||||
var cut = window.LastIndexOf('\n');
|
||||
if (cut < floor)
|
||||
{
|
||||
cut = -1;
|
||||
for (var i = window.Length - 2; i >= floor; i--)
|
||||
{
|
||||
if (window[i] is '.' or '!' or '?' && char.IsWhiteSpace(window[i + 1]))
|
||||
{
|
||||
cut = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cut < floor)
|
||||
cut = window.LastIndexOf(' ');
|
||||
if (cut < floor)
|
||||
cut = window.Length;
|
||||
|
||||
var kept = DropUnpairedMarker(DropUnpairedMarker(window[..cut], "**"), "`");
|
||||
return kept.TrimEnd() + marker;
|
||||
}
|
||||
|
||||
private static string DropUnpairedMarker(string text, string markdownMarker)
|
||||
{
|
||||
var count = 0;
|
||||
var last = -1;
|
||||
for (var i = text.IndexOf(markdownMarker, StringComparison.Ordinal); i >= 0;
|
||||
i = text.IndexOf(markdownMarker, i + markdownMarker.Length, StringComparison.Ordinal))
|
||||
{
|
||||
count++;
|
||||
last = i;
|
||||
}
|
||||
return count % 2 == 0 ? text : text.Remove(last, markdownMarker.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a notification alert for an SLA breach. Dispatches on
|
||||
/// <paramref name="breachType"/>: <c>SuccessRate</c>, <c>Duration</c>, or <c>AppInstall</c>.
|
||||
|
||||
@@ -1307,6 +1307,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return (true, primary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same-event fields copied into every evidence entry. Whitelist: short identifiers only;
|
||||
/// errorDetail/message are excluded (can be multi-KB). Every renderer resolves these as
|
||||
/// <c>{{token}}</c> — the web, the MCP server and <see cref="RuleTemplateInterpolator"/>
|
||||
/// carry the same list, pinned by tests/fixtures/rule-template-interpolation/cases.json.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyList<string> EvidenceAutoFields = new[]
|
||||
{
|
||||
"appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adds slim identifying data fields from an event to the evidence dictionary with a prefix.
|
||||
/// Free-text fields like <c>errorDetail</c> (often a stack trace) and <c>message</c> are
|
||||
@@ -1317,10 +1328,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (evt.Data == null) return;
|
||||
|
||||
// Whitelist: short identifiers only. errorDetail/message removed (can be multi-KB).
|
||||
var knownFields = new[] { "appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours" };
|
||||
|
||||
foreach (var field in knownFields)
|
||||
foreach (var field in EvidenceAutoFields)
|
||||
{
|
||||
var val = GetDataFieldValue(evt, field);
|
||||
if (val != null)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <c>{{token}}</c> placeholders of a rule's explanation/remediation text from a
|
||||
/// result's <see cref="RuleResult.MatchedConditions"/>. A stored <see cref="RuleResult"/> keeps
|
||||
/// the rule's raw template; the web (<c>lib/interpolateRuleTemplate.ts</c>) and the MCP server
|
||||
/// (<c>src/interpolate-rule-template.ts</c>) resolve it when they render. This is the backend
|
||||
/// copy for text that leaves without passing either of them (notifications).
|
||||
/// <para>
|
||||
/// Resolution order, identical in all three and pinned by
|
||||
/// <c>tests/fixtures/rule-template-interpolation/cases.json</c>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>an evidence entry whose <c>field</c> equals the token (the last such entry wins);</item>
|
||||
/// <item>a same-event auto-field named like the token (<see cref="RuleEngine.EvidenceAutoFields"/>;
|
||||
/// the first non-empty one wins, which pins it to the rule's earliest matched condition);</item>
|
||||
/// <item>an evidence entry whose signal name equals the token;</item>
|
||||
/// <item>otherwise the token stays as written.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class RuleTemplateInterpolator
|
||||
{
|
||||
/// <summary>Shown in a notification where running text names a value that was not recorded.</summary>
|
||||
internal const string NotRecorded = "–";
|
||||
|
||||
private static readonly Regex TokenRegex = new Regex(
|
||||
@"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex ListItemRegex = new Regex(
|
||||
@"^\s*(?:[-*+]|\d+[.)])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SentenceBreakRegex = new Regex(
|
||||
@"(?<=[.!?])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex BlankRunRegex = new Regex(
|
||||
@"\n{3,}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every resolvable token; an unresolved one stays literal, exactly as the web
|
||||
/// and the MCP server render it.
|
||||
/// </summary>
|
||||
public static string Interpolate(string? text, IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return string.Empty;
|
||||
|
||||
var lookup = Lookup.From(matchedConditions);
|
||||
return TokenRegex.Replace(text, m => lookup.Resolve(m.Groups[1].Value) ?? m.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpolation for text that leaves the backend as a notification. The recipient has no
|
||||
/// renderer and no footnote explaining a literal placeholder, so none may survive: a list
|
||||
/// item whose token was not recorded is dropped, a token in running text becomes
|
||||
/// <see cref="NotRecorded"/>, and a sentence that talks about literal placeholders is removed.
|
||||
/// </summary>
|
||||
public static string InterpolateForNotification(string? text, IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return string.Empty;
|
||||
|
||||
var lookup = Lookup.From(matchedConditions);
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var unresolved = false;
|
||||
var rendered = TokenRegex.Replace(line, m =>
|
||||
{
|
||||
var value = lookup.Resolve(m.Groups[1].Value);
|
||||
if (value != null)
|
||||
return value;
|
||||
unresolved = true;
|
||||
return NotRecorded;
|
||||
});
|
||||
|
||||
if (unresolved && ListItemRegex.IsMatch(line))
|
||||
continue;
|
||||
|
||||
if (rendered.Contains("{{", StringComparison.Ordinal))
|
||||
{
|
||||
rendered = string.Join(" ", SentenceBreakRegex.Split(rendered)
|
||||
.Where(sentence => !sentence.Contains("{{", StringComparison.Ordinal)));
|
||||
if (rendered.Length == 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.Add(rendered);
|
||||
}
|
||||
|
||||
return BlankRunRegex.Replace(string.Join("\n", lines), "\n\n").Trim();
|
||||
}
|
||||
|
||||
private sealed class Lookup
|
||||
{
|
||||
private readonly Dictionary<string, string> _byField = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _byAutoField = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _bySignal = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
public string? Resolve(string token)
|
||||
{
|
||||
if (_byField.TryGetValue(token, out var value)) return value;
|
||||
if (_byAutoField.TryGetValue(token, out value)) return value;
|
||||
if (_bySignal.TryGetValue(token, out value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Lookup From(IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
var lookup = new Lookup();
|
||||
if (matchedConditions == null || matchedConditions.Count == 0)
|
||||
return lookup;
|
||||
|
||||
// Evidence arrives in two shapes: nested dictionaries straight from the rule engine,
|
||||
// or whatever the table read produced. Going through JSON gives both the form the
|
||||
// web and the MCP server see. JsonSerializer.Create() ignores the process-wide
|
||||
// default settings, so a naming strategy configured there cannot rename the keys.
|
||||
JObject root;
|
||||
try
|
||||
{
|
||||
root = JObject.FromObject(matchedConditions, JsonSerializer.Create());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Evidence that cannot be serialized resolves nothing; the text is still sent.
|
||||
return lookup;
|
||||
}
|
||||
|
||||
foreach (var property in root.Properties())
|
||||
{
|
||||
if (property.Value is not JObject evidence)
|
||||
{
|
||||
if (property.Value is JValue scalar && scalar.Type != JTokenType.Null && scalar.Type != JTokenType.Undefined)
|
||||
lookup._bySignal[property.Name] = Format(scalar);
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasValue = evidence.TryGetValue("value", out var valueToken);
|
||||
if (hasValue)
|
||||
{
|
||||
if (evidence["field"] is JValue { Type: JTokenType.String } field)
|
||||
lookup._byField[(string)field.Value!] = Format(valueToken);
|
||||
lookup._bySignal[property.Name] = Format(valueToken);
|
||||
}
|
||||
|
||||
foreach (var autoField in RuleEngine.EvidenceAutoFields)
|
||||
{
|
||||
if (lookup._byAutoField.ContainsKey(autoField))
|
||||
continue;
|
||||
var formatted = Format(evidence[autoField]);
|
||||
if (formatted.Length > 0)
|
||||
lookup._byAutoField[autoField] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
// Mirrors the TypeScript formatValue: strings as they are, numbers and booleans the
|
||||
// way JavaScript prints them, anything structured as compact JSON.
|
||||
private static string Format(JToken? token)
|
||||
{
|
||||
if (token == null)
|
||||
return string.Empty;
|
||||
|
||||
switch (token.Type)
|
||||
{
|
||||
case JTokenType.Null:
|
||||
case JTokenType.Undefined:
|
||||
return string.Empty;
|
||||
case JTokenType.String:
|
||||
return (string)((JValue)token).Value!;
|
||||
case JTokenType.Boolean:
|
||||
return (bool)token ? "true" : "false";
|
||||
case JTokenType.Integer:
|
||||
return Convert.ToString(((JValue)token).Value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
case JTokenType.Float:
|
||||
return Convert.ToDouble(((JValue)token).Value, CultureInfo.InvariantCulture)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
case JTokenType.Object:
|
||||
case JTokenType.Array:
|
||||
return token.ToString(Formatting.None);
|
||||
default:
|
||||
// Date, Guid, TimeSpan, Uri: their JSON string form without the quotes.
|
||||
return token.ToString(Formatting.None).Trim('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,8 +291,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Collapses report findings to one entity per CVE. Merge rule: the occurrence with the
|
||||
/// highest CVSS score supplies score/severity/software name (ties keep report order),
|
||||
/// IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences.
|
||||
/// highest CVSS score supplies score/severity/software name/product (ties keep report
|
||||
/// order), IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences.
|
||||
/// Product is the finding's CPE "vendor:product" (<see cref="Vulnerability.CpeMatchScorer.ProductKey"/>).
|
||||
/// </summary>
|
||||
internal static List<TableEntity> BuildCveIndexEntities(string tenantId, string sessionId, List<Dictionary<string, object>> findings)
|
||||
{
|
||||
@@ -302,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var softwareName = finding.TryGetValue("softwareName", out var sn) ? sn?.ToString() ?? "" : "";
|
||||
var product = Vulnerability.CpeMatchScorer.ProductKey(finding.TryGetValue("cpeUri", out var cu) ? cu?.ToString() : null);
|
||||
var overallRisk = finding.TryGetValue("riskLevel", out var rl) ? rl?.ToString() ?? "" : "";
|
||||
|
||||
if (!finding.TryGetValue("vulnerabilities", out var vulnsObj) || vulnsObj == null) continue;
|
||||
@@ -330,6 +332,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
existing["CvssScore"] = cvssScore;
|
||||
existing["CvssSeverity"] = cvssSeverity;
|
||||
existing["SoftwareName"] = softwareName;
|
||||
existing["Product"] = product;
|
||||
}
|
||||
existing["IsKev"] = existing.GetBoolean("IsKev") == true || isKev;
|
||||
if (epssScore.HasValue && epssScore.Value > (existing.GetDouble("EpssScore") ?? -1))
|
||||
@@ -347,6 +350,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["TenantId"] = tenantId,
|
||||
["CveId"] = cveId,
|
||||
["SoftwareName"] = softwareName,
|
||||
["Product"] = product,
|
||||
["CvssScore"] = cvssScore,
|
||||
["CvssSeverity"] = cvssSeverity,
|
||||
["IsKev"] = isKev,
|
||||
@@ -1380,7 +1384,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var select = new[]
|
||||
{
|
||||
"SessionId", "TenantId", "CveId", "SoftwareName",
|
||||
"SessionId", "TenantId", "CveId", "SoftwareName", "Product",
|
||||
"CvssScore", "CvssSeverity", "IsKev", "EpssScore", "Priority", "OverallRisk", "DetectedAt",
|
||||
};
|
||||
|
||||
@@ -1406,6 +1410,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
CveId = e.GetString("CveId") ?? string.Empty,
|
||||
SessionId = e.GetString("SessionId") ?? e.RowKey,
|
||||
SoftwareName = e.GetString("SoftwareName") ?? string.Empty,
|
||||
Product = e.GetString("Product") ?? string.Empty,
|
||||
CvssScore = e.GetDouble("CvssScore") ?? 0,
|
||||
CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty,
|
||||
IsKev = e.GetBoolean("IsKev") ?? false,
|
||||
|
||||
@@ -148,6 +148,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
return (parts[2], parts[3], parts[4]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product identity of a CPE 2.3 name as "vendor:product" in lower case
|
||||
/// ("cpe:2.3:a:7-zip:7-zip:24.08" → "7-zip:7-zip"); empty when the name has no concrete
|
||||
/// vendor and product. CveIndex rows carry it so the exposure summary can fold every title
|
||||
/// and version of one product together.
|
||||
/// </summary>
|
||||
public static string ProductKey(string? cpeName)
|
||||
{
|
||||
var parts = ParseCpeComponents(cpeName ?? "");
|
||||
if (parts == null) return "";
|
||||
var (_, vendor, product) = parts.Value;
|
||||
if (!IsConcrete(vendor) || !IsConcrete(product)) return "";
|
||||
return $"{vendor}:{product}".ToLowerInvariant();
|
||||
|
||||
static bool IsConcrete(string value) => value.Length > 0 && value != "*" && value != "-";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a CpeMappingEntry from an NVD CPE entry for saving as a custom mapping.
|
||||
/// </summary>
|
||||
|
||||
+7
-7
@@ -28,7 +28,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
@@ -2284,9 +2284,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3752,9 +3752,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
|
||||
@@ -10,9 +10,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { scanUntilMatch } from '../client.js';
|
||||
import {
|
||||
isKnownEventType,
|
||||
isGatherOutputEventType,
|
||||
isFilterableEventType,
|
||||
assertKnownEventType,
|
||||
withEventTypeNote,
|
||||
assertKnownDevicePropertyKeys,
|
||||
eventTypePrefixOf,
|
||||
ALL_DEVICE_PROPERTY_KEYS,
|
||||
@@ -146,50 +144,59 @@ describe('scanUntilMatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('event-type validation', () => {
|
||||
it('accepts known event types', () => {
|
||||
describe('event-type filter', () => {
|
||||
it('keeps isKnownEventType strict: built-in types only', () => {
|
||||
expect(isKnownEventType('app_install_failed')).toBe(true);
|
||||
expect(isKnownEventType('enrollment_failed')).toBe(true);
|
||||
expect(() => assertKnownEventType('app_install_failed')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown event type with a helpful, suggestion-bearing error', () => {
|
||||
expect(isKnownEventType('made_up_type')).toBe(false);
|
||||
expect(() => assertKnownEventType('app_install_fialed')).toThrow(/event_types catalog/);
|
||||
// "app_install_fialed" shares the "app" token → at least one suggestion surfaces.
|
||||
expect(() => assertKnownEventType('app_install_fialed')).toThrow(/Did you mean/);
|
||||
});
|
||||
|
||||
it('accepts the hybrid user-affinity types added to the catalog', () => {
|
||||
expect(isKnownEventType('gather_result')).toBe(true);
|
||||
for (const t of ['desktop_real_user_detected', 'desktop_excluded_user', 'ime_user_token_acquired', 'entra_user_affinity_pending', 'device_registration_event']) {
|
||||
expect(isKnownEventType(t), t).toBe(true);
|
||||
expect(() => assertKnownEventType(t)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets gather-rule output types through the filter gate without cataloguing them', () => {
|
||||
// Built-in and tenant-authored outputEventType values are rule data, never catalogued.
|
||||
for (const t of ['gather_dsregcmd_status', 'gather_custom_anything']) {
|
||||
expect(isGatherOutputEventType(t), t).toBe(true);
|
||||
expect(isFilterableEventType(t), t).toBe(true);
|
||||
expect(() => assertKnownEventType(t)).not.toThrow();
|
||||
// The strict predicate stays strict: rule-validation uses it as "is a built-in type".
|
||||
// Gather-rule output types are rule data, never catalogued — rule-validation relies on this.
|
||||
for (const t of ['gather_dsregcmd_status', 'HPiA-UpdateStatus', 'made_up_type']) {
|
||||
expect(isKnownEventType(t), t).toBe(false);
|
||||
}
|
||||
// Catalogued code-emitted gather_* lifecycle types are known AND filterable.
|
||||
expect(isKnownEventType('gather_result')).toBe(true);
|
||||
expect(isFilterableEventType('gather_result')).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a non-empty suffix after the gather_ prefix', () => {
|
||||
expect(isGatherOutputEventType('gather_')).toBe(false);
|
||||
expect(isFilterableEventType('gather_')).toBe(false);
|
||||
expect(() => assertKnownEventType('gather_')).toThrow(/event_types catalog/);
|
||||
it('passes a result for a built-in type through unchanged, even when empty', () => {
|
||||
const empty = { success: true, count: 0, events: [] };
|
||||
expect(withEventTypeNote(empty, 'app_install_failed', true)).toBe(empty);
|
||||
});
|
||||
|
||||
it('still rejects a typo that only resembles the gather_ prefix', () => {
|
||||
expect(isFilterableEventType('gathre_dsregcmd_status')).toBe(false);
|
||||
expect(() => assertKnownEventType('gathre_dsregcmd_status')).toThrow(/Unknown eventType "gathre_dsregcmd_status" — it is not in the event_types catalog/);
|
||||
it('passes events of a gather rule\'s own type through unchanged, whatever its spelling', () => {
|
||||
// Regression anchor: "HPiA-UpdateStatus" was rejected because it is neither catalogued nor gather_-prefixed.
|
||||
for (const t of ['HPiA-UpdateStatus', 'CustomNetworkStatus', 'gather_dsregcmd_status']) {
|
||||
const page = { success: true, count: 1, events: [{ eventType: t }] };
|
||||
expect(withEventTypeNote(page, t, true), t).toBe(page);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks an exhausted empty result for an uncatalogued type and names close built-in types', () => {
|
||||
const res = withEventTypeNote({ success: true, count: 0, events: [] }, 'app_install_fialed', true);
|
||||
|
||||
expect(res.eventTypeNote).toMatch(/"app_install_fialed" is not a built-in event type and no event of it was found/);
|
||||
// "app_install_fialed" shares the "app" token → at least one suggestion surfaces.
|
||||
expect(res.eventTypeNote).toMatch(/Closest built-in types: .*app_/);
|
||||
});
|
||||
|
||||
it('leaves a page alone that is not the final answer yet', () => {
|
||||
expect(withEventTypeNote({ count: 0, events: [], nextLink: '/x?continuation=t' }, 'custom', true).eventTypeNote).toBeUndefined();
|
||||
expect(withEventTypeNote({ count: 0, events: [], moreToScan: true }, 'custom', true).eventTypeNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays silent when the type was not the only filter of a first call', () => {
|
||||
// Another filter (severity, source, time) or an earlier page of the sweep may explain the
|
||||
// emptiness — blaming the type would steer the reader towards a typo that is not there.
|
||||
expect(withEventTypeNote({ count: 0, events: [] }, 'HPiA-UpdateStatus', false).eventTypeNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises the sessions[] shape of search_sessions_by_event', () => {
|
||||
expect(withEventTypeNote({ success: true, count: 0, sessions: [] }, 'HPiA-UpdateStatus', true).eventTypeNote)
|
||||
.toMatch(/not a built-in event type/);
|
||||
});
|
||||
|
||||
it('does nothing without an eventType filter', () => {
|
||||
const empty = { count: 0, events: [] };
|
||||
expect(withEventTypeNote(empty, undefined, true)).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* eventType filters accept a gather rule's own output type. Regression anchor: get_session_events
|
||||
* rejected "HPiA-UpdateStatus" — a tenant rule's outputEventType — because it is neither catalogued
|
||||
* nor gather_-prefixed, so the rule's events could not be filtered at all. An empty result for an
|
||||
* uncatalogued type is marked instead of rejected up front.
|
||||
*
|
||||
* Pure unit tests: fetch is stubbed and the registered tool handlers are invoked directly.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { registerTools } from '../tools.js';
|
||||
import { runWithCaller } from '../client.js';
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
|
||||
const GA = { token: 'ga', isGlobalAdmin: true };
|
||||
const extra = { signal: new AbortController().signal };
|
||||
|
||||
function handlerFor(name: string): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, undefined, true, true, false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered`);
|
||||
return tool.handler;
|
||||
}
|
||||
|
||||
function stubFetch(body: Record<string, unknown>): { urls: string[] } {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
urls.push(decodeURIComponent(String(url)));
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
|
||||
}));
|
||||
return { urls };
|
||||
}
|
||||
|
||||
function resultJson(r: { content?: Array<{ text?: string }> }): Record<string, unknown> {
|
||||
return JSON.parse((r.content ?? []).map((c) => c.text ?? '').join('')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('eventType filter with a gather rule\'s own output type', () => {
|
||||
it('get_session_events filters by the custom type and returns its events unmarked', async () => {
|
||||
const { urls } = stubFetch({ success: true, count: 1, events: [{ eventType: 'HPiA-UpdateStatus', sequence: 69 }] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'HPiA-UpdateStatus' }, extra));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(urls[0]).toContain('eventType=HPiA-UpdateStatus');
|
||||
const body = resultJson(result);
|
||||
expect(body.count).toBe(1);
|
||||
expect(body).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('an empty result for an uncatalogued type carries the note instead of passing as "nothing happened"', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'app_install_fialed' }, extra));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(String(resultJson(result).eventTypeNote)).toMatch(/"app_install_fialed" is not a built-in event type.*Closest built-in types/);
|
||||
});
|
||||
|
||||
it('no note when severity, not the type, may have emptied the result, or on a continued sweep', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const filtered = await runWithCaller(GA, () => handlerFor('get_session_events')(
|
||||
{ sessionId: SESSION, eventType: 'HPiA-UpdateStatus', severity: 'Error' }, extra));
|
||||
expect(resultJson(filtered)).not.toHaveProperty('eventTypeNote');
|
||||
|
||||
const continued = await runWithCaller(GA, () => handlerFor('search_sessions_by_event')(
|
||||
{ eventType: 'HPiA-UpdateStatus', continuation: '/api/global/search/sessions-by-event?eventType=HPiA-UpdateStatus&continuation=tok' }, extra));
|
||||
expect(resultJson(continued)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('a built-in type keeps a plain empty result', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'app_install_failed' }, extra));
|
||||
|
||||
expect(resultJson(result)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('search_sessions_by_event and query_raw_events accept the custom type too', async () => {
|
||||
stubFetch({ success: true, count: 0, sessions: [] });
|
||||
const bySession = await runWithCaller(GA, () => handlerFor('search_sessions_by_event')({ eventType: 'CustomNetworkStatus' }, extra));
|
||||
expect(bySession.isError).toBeFalsy();
|
||||
expect(String(resultJson(bySession).eventTypeNote)).toMatch(/not a built-in event type/);
|
||||
|
||||
const { urls } = stubFetch({ success: true, count: 1, events: [{ EventType: 'CustomNetworkStatus' }] });
|
||||
const raw = await runWithCaller(GA, () => handlerFor('query_raw_events')({ eventType: 'CustomNetworkStatus' }, extra));
|
||||
expect(raw.isError).toBeFalsy();
|
||||
expect(urls[0]).toContain('eventType=CustomNetworkStatus');
|
||||
expect(resultJson(raw)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -1,5 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { interpolateRuleTemplate, interpolateAnalysisResults } from '../interpolate-rule-template.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
interpolateRuleTemplate,
|
||||
interpolateAnalysisResults,
|
||||
RULE_TEMPLATE_AUTO_FIELDS,
|
||||
} from '../interpolate-rule-template.js';
|
||||
|
||||
interface SharedCase {
|
||||
name: string;
|
||||
template: string | null;
|
||||
matchedConditions: Record<string, unknown> | null;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
/** tests/fixtures/rule-template-interpolation at the repo root — the cases every renderer runs. */
|
||||
function loadSharedCases(): { autoFields: string[]; cases: SharedCase[] } {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
while (!existsSync(join(dir, 'AutopilotMonitor.sln'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) throw new Error('repository root (AutopilotMonitor.sln) not found');
|
||||
dir = parent;
|
||||
}
|
||||
return JSON.parse(
|
||||
readFileSync(join(dir, 'tests', 'fixtures', 'rule-template-interpolation', 'cases.json'), 'utf8'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('interpolateRuleTemplate — shared cases (web = MCP = backend)', () => {
|
||||
const shared = loadSharedCases();
|
||||
|
||||
it('has cases and the same auto-field list as the rule engine', () => {
|
||||
expect(shared.cases.length).toBeGreaterThanOrEqual(20);
|
||||
expect([...RULE_TEMPLATE_AUTO_FIELDS]).toEqual(shared.autoFields);
|
||||
});
|
||||
|
||||
it.each(shared.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
|
||||
expect(interpolateRuleTemplate(c.template, c.matchedConditions)).toBe(c.expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateRuleTemplate', () => {
|
||||
// Shape of a real ANALYZE-ENRL-001 matchedConditions map (the case from the
|
||||
|
||||
@@ -680,7 +680,7 @@ const defaultPageFetcher: PageFetcher = (path) => apiFetch<Record<string, unknow
|
||||
const ITEM_ARRAY_KEYS = ['events', 'sessions', 'items', 'results'] as const;
|
||||
|
||||
/** Find the first array-valued envelope field — the page's item list. */
|
||||
function extractItems(page: Record<string, unknown>): unknown[] | undefined {
|
||||
export function extractItems(page: Record<string, unknown>): unknown[] | undefined {
|
||||
for (const key of ITEM_ARRAY_KEYS) {
|
||||
const v = page[key];
|
||||
if (Array.isArray(v)) return v;
|
||||
|
||||
@@ -1043,7 +1043,7 @@ export interface CollectorConfiguration {
|
||||
enableAgentSelfMetrics: boolean;
|
||||
/** Interval in seconds for agent self-metrics snapshots. Default: 60 seconds */
|
||||
agentSelfMetricsIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum agent lifetime in minutes. Safety net to prevent zombie agents. 0 = disabled (no lifetime limit). Default: 360 (6 hours). */
|
||||
agentMaxLifetimeMinutes: number;
|
||||
@@ -1247,10 +1247,21 @@ export interface CveExposureSummary {
|
||||
/** Distinct affected tenants; null (key omitted) on tenant-scoped reads. */
|
||||
totalAffectedTenants?: number;
|
||||
distinctCves: number;
|
||||
/** Distinct affected products, grouped as in TopSoftware, with at least one CVE in the window. */
|
||||
distinctSoftware: number;
|
||||
kevCves: number;
|
||||
/** Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE. */
|
||||
kevAffectedSessions: number;
|
||||
severityBreakdown: SeverityBreakdown;
|
||||
priorityBreakdown: PriorityBreakdown;
|
||||
/** The topN CVEs by affected sessions, plus the KEV CVEs that rank below them (at most 25 extra), all ordered by affected sessions. The list can therefore be longer than topN. */
|
||||
topCves: TopCve[];
|
||||
/** The top min(topN, 20) products by affected sessions, plus the products with a KEV CVE that rank below them (at most 10 extra), ordered by affected sessions. */
|
||||
topSoftware: TopSoftware[];
|
||||
/** For list filters: per CVSS severity band and per priority band, the band's top CVEs by affected sessions that TopCves does not already hold (at most perBand per band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise. */
|
||||
bandCves?: TopCve[];
|
||||
/** The same for products, banded by their most severe CVE and their highest priority, minus the entries of TopSoftware. Only on request; null (key omitted) otherwise. */
|
||||
bandSoftware?: TopSoftware[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@@ -4477,6 +4488,17 @@ export interface SleepSpan {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** One CVE of a TopSoftware entry; AffectedSessions counts that product's sessions only. */
|
||||
export interface SoftwareCve {
|
||||
cveId: string;
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
isKev: boolean;
|
||||
epssScore?: number;
|
||||
priority: string;
|
||||
affectedSessions: number;
|
||||
}
|
||||
|
||||
/** Shared response of GET vulnerability/software-inventory (Global Admin, ?tenantId=) and GET metrics/software-inventory (caller's own tenant): the raw SoftwareInventory rows plus matched/unmatched counts. Consumed by the MCP get_software_inventory tool — key names are part of that contract. */
|
||||
export interface SoftwareInventoryResponse {
|
||||
success: boolean;
|
||||
@@ -4810,7 +4832,7 @@ export interface TenantConfiguration {
|
||||
enablePerformanceCollector: boolean;
|
||||
/** Performance collector interval in seconds Default: 30 seconds */
|
||||
performanceCollectorIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum consecutive authentication failures (401/403) before the agent shuts down. null = use default (5). 0 = disabled (retry forever). */
|
||||
maxAuthFailures?: number | null;
|
||||
@@ -5004,6 +5026,8 @@ export interface TenantGroup {
|
||||
assignees: TenantGroupAssignment[];
|
||||
/** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */
|
||||
ownerTenantId?: string;
|
||||
/** Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer. */
|
||||
customerLabel?: string;
|
||||
}
|
||||
|
||||
/** One UPN→group assignment. PK=UPN, RK=groupId in storage. */
|
||||
@@ -5036,7 +5060,7 @@ export interface TenantManagerItem {
|
||||
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
|
||||
ownerTenantId?: string;
|
||||
ownerDomain?: string;
|
||||
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
|
||||
/** The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer. */
|
||||
name: string;
|
||||
/** self-service | operator */
|
||||
source: string;
|
||||
@@ -5212,6 +5236,26 @@ export interface TopFailingApp {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
/** One affected product: every CveIndex row of the product folded together. Severity, score and priority are the worst values across its CVEs; AffectedSessions counts distinct sessions. */
|
||||
export interface TopSoftware {
|
||||
/** Grouping key: the CPE "vendor:product" the correlation matched (e.g. "7-zip:7-zip"). A row written before the index carried it borrows the product of another row with the same title; failing that, its raw title is the key. */
|
||||
product: string;
|
||||
/** Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture). */
|
||||
name: string;
|
||||
affectedSessions: number;
|
||||
cveCount: number;
|
||||
kevCount: number;
|
||||
/** Highest CVSS severity band across the product's CVEs. */
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
/** Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it). */
|
||||
priority: string;
|
||||
/** Raw display names the product was detected under, most sessions first (at most 3). */
|
||||
titles: string[];
|
||||
/** The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; CveCount is the full count. */
|
||||
cves: SoftwareCve[];
|
||||
}
|
||||
|
||||
/** One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */
|
||||
export interface TpmPssUnsupportedItem {
|
||||
serialNumber: string;
|
||||
@@ -5339,10 +5383,12 @@ export interface UpdateTenantAppHomingResponse {
|
||||
probe: AppHomingProbeWire;
|
||||
}
|
||||
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required. */
|
||||
export interface UpdateTenantGroupRequest {
|
||||
/** New display name. */
|
||||
/** New display name (Global Admin UI only). */
|
||||
name?: string | null;
|
||||
/** The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups. */
|
||||
customerLabel?: string | null;
|
||||
}
|
||||
|
||||
/** Response of PUT sessions/{sessionId}/annotations/{lane} when both verdict and note were empty and the lane was cleared. */
|
||||
|
||||
@@ -8,15 +8,33 @@
|
||||
* Keep the resolution order in lock-step with the web copy:
|
||||
* 1. matchedConditions entry whose `field` equals `token`.
|
||||
* 2. matchedConditions entry carrying a whitelisted same-event auto-field
|
||||
* named `token` (backend `AddDataFieldsToEvidence`: appId, appName,
|
||||
* errorPatternId, errorCode, exitCode, status). First non-empty wins so the
|
||||
* named `token` (`RULE_TEMPLATE_AUTO_FIELDS`). First non-empty wins so the
|
||||
* value pins to the rule's required-condition event.
|
||||
* 3. matchedConditions entry whose key (signal name) equals `token`.
|
||||
* 4. Unresolved tokens are left untouched so authors notice the typo (and the
|
||||
* reworded ANALYZE-ENRL-001 footnote explains a genuinely-absent field).
|
||||
*
|
||||
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
|
||||
*
|
||||
* Web, MCP and the backend (notifications) all run the cases in
|
||||
* `tests/fixtures/rule-template-interpolation/cases.json` at the repo root.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirror of the backend evidence auto-capture whitelist (`RuleEngine.EvidenceAutoFields`).
|
||||
* Explicit list keeps a rogue evidence key (e.g. accidental `description`) from shadowing
|
||||
* a token. Also the list validate_rule lints tokens against.
|
||||
*/
|
||||
export const RULE_TEMPLATE_AUTO_FIELDS = [
|
||||
'appId',
|
||||
'appName',
|
||||
'errorPatternId',
|
||||
'errorCode',
|
||||
'exitCode',
|
||||
'status',
|
||||
'timeoutHours',
|
||||
] as const;
|
||||
|
||||
export function interpolateRuleTemplate(
|
||||
text: string | null | undefined,
|
||||
matchedConditions: Record<string, unknown> | null | undefined
|
||||
@@ -28,10 +46,6 @@ export function interpolateRuleTemplate(
|
||||
const byAutoField = new Map<string, string>();
|
||||
const bySignal = new Map<string, string>();
|
||||
|
||||
// Mirror of the backend AddDataFieldsToEvidence whitelist. Explicit list keeps a
|
||||
// rogue evidence key (e.g. accidental `description`) from shadowing a token.
|
||||
const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
|
||||
|
||||
for (const [signal, evidence] of Object.entries(matchedConditions)) {
|
||||
if (!evidence || typeof evidence !== 'object') {
|
||||
if (evidence != null) bySignal.set(signal, formatValue(evidence));
|
||||
@@ -44,7 +58,7 @@ export function interpolateRuleTemplate(
|
||||
if ('value' in e) {
|
||||
bySignal.set(signal, formatValue(e.value));
|
||||
}
|
||||
for (const af of AUTO_FIELDS) {
|
||||
for (const af of RULE_TEMPLATE_AUTO_FIELDS) {
|
||||
if (byAutoField.has(af)) continue;
|
||||
const v = e[af];
|
||||
if (v != null && v !== '') byAutoField.set(af, formatValue(v));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* (MCP-protocol resources) and the `get_resource` tool (workaround for
|
||||
* stateless-HTTP MCP clients whose resource discovery is broken).
|
||||
*/
|
||||
import { extractItems } from './client.js';
|
||||
import { DIAG_ZIP_MAP } from './diag-zip-map.js';
|
||||
import { RULE_AUTHORING_GUIDE } from './rule-authoring-guide.js';
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
// (all catalog values ∪ INTERNAL_EVENT_TYPES) === the C# const values. When the
|
||||
// agent adds a new event type to Constants.EventTypes, add it to a group here too.
|
||||
// Gather-rule output types ("gather_dsregcmd_status", ...) are rule data, not code-emitted
|
||||
// types, and stay OUT of the catalog on purpose — see isGatherOutputEventType below.
|
||||
// types, and stay OUT of the catalog on purpose — see withEventTypeNote below.
|
||||
export const EVENT_TYPES_CATALOG = {
|
||||
phase_events: [
|
||||
'phase_transition',
|
||||
@@ -357,38 +358,20 @@ export const DEVICE_PROPERTIES_CATALOG = {
|
||||
//
|
||||
// Without these, an invalid eventType / deviceProperties key produces the SAME
|
||||
// `count: 0` as a genuine miss, so "bad filter" is indistinguishable from "no
|
||||
// matches". The search tools call these to reject a typo with a clear, correctable
|
||||
// error instead of a silent empty result.
|
||||
// matches". The tools reject a deviceProperties typo with a clear error and mark an
|
||||
// empty result for an uncatalogued eventType with a correctable note.
|
||||
|
||||
/** Set form of ALL_EVENT_TYPES for O(1) membership checks. */
|
||||
const EVENT_TYPE_SET = new Set<string>(ALL_EVENT_TYPES);
|
||||
|
||||
/** True if `s` is a known event type (public catalog ∪ internal). */
|
||||
/**
|
||||
* True if `s` is a built-in event type (public catalog ∪ internal). Strict on purpose:
|
||||
* rule-validation relies on it for its outputEventType collision check.
|
||||
*/
|
||||
export function isKnownEventType(s: string): boolean {
|
||||
return EVENT_TYPE_SET.has(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather-rule output types (`outputEventType`, e.g. "gather_dsregcmd_status") are rule
|
||||
* DATA — built-in and tenant-authored — not code-emitted types, so they are deliberately
|
||||
* NOT catalogued: D-072 scopes the catalog to `Constants.EventTypes`, and the C# guard
|
||||
* test excludes gather outputs for the same reason. They are real events on the wire
|
||||
* nonetheless, so the filter gate lets any `gather_`-prefixed type (with a non-empty
|
||||
* suffix) through — the same "a valid prefix passes even when the full key is
|
||||
* uncatalogued" stance as assertKnownDevicePropertyKeys. Kept apart from
|
||||
* isKnownEventType, whose strict "is a built-in type" meaning rule-validation relies
|
||||
* on for its outputEventType collision check.
|
||||
*/
|
||||
const GATHER_OUTPUT_PREFIX = 'gather_';
|
||||
export function isGatherOutputEventType(s: string): boolean {
|
||||
return s.startsWith(GATHER_OUTPUT_PREFIX) && s.length > GATHER_OUTPUT_PREFIX.length;
|
||||
}
|
||||
|
||||
/** True if `s` is accepted as an eventType filter: catalogued, internal, or a gather-rule output. */
|
||||
export function isFilterableEventType(s: string): boolean {
|
||||
return isKnownEventType(s) || isGatherOutputEventType(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every catalogued deviceProperties key, flattened across event-type groups
|
||||
* (excluding the `_usage` doc block). This is a CURATED subset — not exhaustive —
|
||||
@@ -415,19 +398,35 @@ function suggestEventTypes(input: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a descriptive Error if `eventType` is neither a known type nor a
|
||||
* gather-rule output (see isFilterableEventType). The thrown message routes
|
||||
* through the tools' `toolError` handler so the model gets an actionable
|
||||
* correction instead of a misleading empty result.
|
||||
* eventType filters take ANY type and pass it on as given. Gather-rule output types
|
||||
* (`outputEventType`, e.g. "gather_dsregcmd_status", "HPiA-UpdateStatus") are rule DATA — built-in
|
||||
* and tenant-authored — so they are deliberately NOT catalogued (D-072), yet they are real events
|
||||
* on the wire, and the portal suggests free names like "CustomNetworkStatus". A gate on the catalog
|
||||
* (or on a naming prefix) locked those out. What the gate protected — a typo must not read as
|
||||
* "nothing happened" — this note keeps: when the uncatalogued type was the only filter of a first
|
||||
* call and the answer is an exhausted empty result, it says so and names the closest built-in
|
||||
* types. With other filters or on a continued sweep the emptiness proves nothing about the type,
|
||||
* and non-empty or still-continuable pages pass unchanged.
|
||||
*/
|
||||
export function assertKnownEventType(eventType: string): void {
|
||||
if (isFilterableEventType(eventType)) return;
|
||||
export function withEventTypeNote<T extends object>(
|
||||
data: T,
|
||||
eventType: string | undefined,
|
||||
soleFilterOfFirstCall: boolean,
|
||||
): T & { eventTypeNote?: string } {
|
||||
const page = data as Record<string, unknown>;
|
||||
if (!eventType || !soleFilterOfFirstCall || isKnownEventType(eventType) || page.nextLink || page.moreToScan) return data;
|
||||
const items = extractItems(page);
|
||||
const empty = typeof page.count === 'number' ? page.count === 0 : items !== undefined && items.length === 0;
|
||||
if (!empty) return data;
|
||||
const suggestions = suggestEventTypes(eventType);
|
||||
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
||||
throw new Error(
|
||||
`Unknown eventType "${eventType}" — it is not in the event_types catalog.${hint} ` +
|
||||
'Call get_resource(name="event_types") for the full list of valid event types.',
|
||||
);
|
||||
const hint = suggestions.length > 0 ? ` Closest built-in types: ${suggestions.join(', ')}.` : '';
|
||||
return {
|
||||
...data,
|
||||
eventTypeNote:
|
||||
`"${eventType}" is not a built-in event type and no event of it was found here. Gather rules emit their own ` +
|
||||
`output types (outputEventType) — check the spelling against the rule.${hint} ` +
|
||||
'Built-in types: get_resource(name="event_types").',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -250,7 +250,7 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'explanation and remediation text may contain {{token}} placeholders, resolved from the ' +
|
||||
'matched evidence at display time: first a matched condition whose dataField equals the ' +
|
||||
'token, then the auto-captured fields appId / appName / errorPatternId / errorCode / ' +
|
||||
'exitCode / status from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
|
||||
'exitCode / status / timeoutHours from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
|
||||
'stay visible as {{token}} so typos are noticeable. test_analyze_rule returns the ' +
|
||||
'interpolated preview for a real session.',
|
||||
markSessionAsFailed:
|
||||
|
||||
@@ -27,6 +27,7 @@ import addFormatsModule from 'ajv-formats';
|
||||
const addFormats = addFormatsModule as unknown as (ajv: Ajv2020) => void;
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
|
||||
import { isKnownEventType } from './resource-catalog.js';
|
||||
import { RULE_TEMPLATE_AUTO_FIELDS } from './interpolate-rule-template.js';
|
||||
|
||||
export type FindingLevel = 'error' | 'warning' | 'info';
|
||||
|
||||
@@ -274,9 +275,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
||||
|
||||
// ── Analyze-rule semantic lint ──────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the backend evidence auto-capture whitelist (AddDataFieldsToEvidence) and
|
||||
* the interpolation resolution order (interpolate-rule-template.ts). */
|
||||
const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
|
||||
/** The auto-captured fields a token may resolve to — the interpolator's own list. */
|
||||
const AUTO_FIELDS = RULE_TEMPLATE_AUTO_FIELDS;
|
||||
|
||||
/** The only condition strings EvaluateConfidenceFactor parses — anything else is
|
||||
* silently false in production. Exact spacing matters. */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback, jsonBody } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../resource-catalog.js';
|
||||
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
@@ -1473,8 +1473,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'PartitionKey/RowKey/Timestamp. Deliberately unenriched: DataJson is the raw stored string (not parsed), ' +
|
||||
'Severity/Phase are raw ints, no decoded error meanings; for the enriched stream use get_session_events or ' +
|
||||
'search_events. Use it when search_events does not cover the time range or session scope, or for exact ' +
|
||||
'event-type filtering across many sessions. eventType is validated against the event_types catalog (a typo ' +
|
||||
'is rejected, not an empty result). With a filter the tool auto-scans past empty pages: "count": 0 without ' +
|
||||
'event-type filtering across many sessions. eventType may also be a gather rule\'s own outputEventType; an empty ' +
|
||||
'first-page result for a type outside the event_types catalog carries eventTypeNote with the closest built-in ' +
|
||||
'types. With a filter the tool auto-scans past empty pages: "count": 0 without ' +
|
||||
'nextLink means no matches; "moreToScan": true means the per-call scan budget was hit — continue with nextLink. ' +
|
||||
'pageSize is the index-scan cadence (one indexed session can contribute several events, so a page may hold ' +
|
||||
'more than pageSize). A page that ends early carries "partial": true — nothing is missing up to its nextLink. ' +
|
||||
@@ -1513,7 +1514,6 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// Default projection follows intent — see leanFieldSelection (DataJson is the raw payload column).
|
||||
const { fields, leanDefaultApplied } = leanFieldSelection(explicitFields, continuation, targeted, LEAN_RAW_EVENT_FIELDS);
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
if (eventType) assertKnownEventType(eventType);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
@@ -1526,7 +1526,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// forward so the model isn't misled by an empty-but-continuable page. The server
|
||||
// bounds each page by its own scan budget; should a call still time out, retry once
|
||||
// with a halved pageSize on the same cursor instead of failing identically twice.
|
||||
const data = await scanWithTimeoutFallback(path, basePath, effectivePageSize(pageSize, continuation));
|
||||
const data = withEventTypeNote(
|
||||
await scanWithTimeoutFallback(path, basePath, effectivePageSize(pageSize, continuation)),
|
||||
eventType,
|
||||
!continuation && !severity && !source && !startedAfter && !startedBefore,
|
||||
);
|
||||
// Announce the omission in-band so a reader of the result knows DataJson exists and how to get it.
|
||||
return toolResultText(leanDefaultApplied ? { ...data, ...LEAN_RAW_EVENT_OMISSION } : data, MAX_RESULT_SIZE_CHARS.events);
|
||||
} catch (error: unknown) {
|
||||
@@ -1967,15 +1971,17 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Vulnerability Summary',
|
||||
description:
|
||||
'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' +
|
||||
'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, a priority breakdown ' +
|
||||
'(act/attend/track counts over every CVE in the window), and the top CVEs ranked by how ' +
|
||||
'many devices they affect. Each top CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' +
|
||||
'distinct CVE and product counts, KEV (CISA Known-Exploited) count and the devices carrying one, a severity breakdown, ' +
|
||||
'a priority breakdown (act/attend/track counts over every CVE in the window), the top CVEs ranked by how ' +
|
||||
'many devices they affect (every KEV CVE is added even below topN), and topSoftware: the most exposed products ' +
|
||||
'(grouped by CPE vendor:product, KEV products always added) with devices, CVE and KEV counts, worst severity and ' +
|
||||
'priority, and their most urgent CVEs. Each CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' +
|
||||
'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' +
|
||||
'"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' +
|
||||
'not by CVSS alone. ' +
|
||||
(ga ? 'The cross-tenant overview also returns the affected tenant count. ' : '') +
|
||||
'Use this to answer "how exposed is the fleet / this ' +
|
||||
'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' +
|
||||
'tenant?", "which CVEs affect the most devices?" and "which software to update first?" — for the device list of a single CVE use search_sessions_by_cve. ' +
|
||||
'If "truncated" is true, the underlying index scan hit its cap and counts are a lower bound (narrow with tenantId). ' +
|
||||
'Requires vulnerability scanning to be enabled (an empty summary means no findings, not necessarily "not affected").',
|
||||
inputSchema: {
|
||||
@@ -1983,7 +1989,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe(daysDescription(30, 365, 'Filters CVEs by detection time.')),
|
||||
topN: z.coerce.number().int().min(1).max(100).optional().default(20)
|
||||
.describe('How many top CVEs to return, ranked by affected device count (1-100, default 20).'),
|
||||
.describe('How many top CVEs to return, ranked by affected device count (1-100, default 20); also sizes topSoftware (at most 20).'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { withToolTelemetry } from '../telemetry.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { lookupErrorCode } from '../error-code-catalog.js';
|
||||
import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
import { interpolateAnalysisResults } from '../interpolate-rule-template.js';
|
||||
import { buildSessionCoverage } from '../session-coverage.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
@@ -220,10 +220,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
title: 'Search Sessions by Event',
|
||||
description:
|
||||
'Find sessions that contain a given event type, read from the event-type index (e.g. every session with ' +
|
||||
'app_install_failed or enrollment_failed). eventType is validated against the event_types catalog; an unknown ' +
|
||||
'type is rejected, not an empty result.',
|
||||
'app_install_failed or enrollment_failed). eventType may also be a gather rule\'s own outputEventType; an empty ' +
|
||||
'first-page result for a type outside the event_types catalog carries eventTypeNote with the closest built-in types.',
|
||||
inputSchema: {
|
||||
eventType: z.string().describe('Event type string from get_resource(name="event_types") (e.g. "app_install_failed", "enrollment_failed")'),
|
||||
eventType: z.string().describe('Event type from get_resource(name="event_types") (e.g. "app_install_failed", "enrollment_failed"), or a gather rule\'s own outputEventType'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
|
||||
@@ -237,9 +237,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const pageSize = pageSizeForCall(args.pageSize, continuation, DEFAULT_FIRST_PAGE_SIZE);
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
// eventType is the sole filter and is applied server-side (EventTypeIndex OData),
|
||||
// so an empty page never carries a nextLink — no auto-exhaust needed. But validate
|
||||
// the type so a typo is a clear error rather than a silent empty result.
|
||||
assertKnownEventType(eventType);
|
||||
// so an empty page never carries a nextLink — no auto-exhaust needed. An empty result
|
||||
// for an uncatalogued type says so, so a typo does not read as "no such sessions".
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
@@ -248,7 +247,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{ pageSize },
|
||||
);
|
||||
const data = await apiFetch<SessionListResponse>(path);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.indexSessions);
|
||||
return toolResultText(withEventTypeNote(data, eventType, !continuation), MAX_RESULT_SIZE_CHARS.indexSessions);
|
||||
} catch (error: unknown) {
|
||||
return toolError('search_sessions_by_event', args, error);
|
||||
}
|
||||
@@ -393,8 +392,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
title: 'Get Session Events',
|
||||
description:
|
||||
'RAW EVENT RETRIEVAL (fallback when ranked search_events misses, or when every event of one session is needed ' +
|
||||
'in chronological sequence). Filter by eventType (validated against the event_types catalog; an unknown type is ' +
|
||||
'rejected, not an empty result), severity or source (app name). An unfiltered read omits the per-event "data" ' +
|
||||
'in chronological sequence). Filter by eventType (built-in, or a gather rule\'s own outputEventType; an empty first-page ' +
|
||||
'result for a type outside the event_types catalog carries eventTypeNote), severity or source (app name). An unfiltered read omits the per-event "data" ' +
|
||||
'payload by default and says so in omittedFields; a filtered read includes it (see fields). ' +
|
||||
'Filters are applied after the partition read, so the tool scans forward past empty pages: "count": 0 without ' +
|
||||
'nextLink means no matching events; "moreToScan": true means the per-call scan budget was hit — pass the ' +
|
||||
@@ -402,7 +401,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID'),
|
||||
tenantId: z.string().optional().describe(sessionTenantIdDescription),
|
||||
eventType: z.string().optional().describe('Only events of this type; values from get_resource(name="event_types")'),
|
||||
eventType: z.string().optional().describe('Only events of this type: a value from get_resource(name="event_types"), or a gather rule\'s own outputEventType'),
|
||||
severity: z.enum(EVENT_SEVERITIES).optional(),
|
||||
source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'),
|
||||
fields: z.string().optional()
|
||||
@@ -420,7 +419,6 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// Default projection follows intent — see leanFieldSelection.
|
||||
const { fields, leanDefaultApplied } = leanFieldSelection(explicitFields, continuation, Boolean(eventType || severity || source), LEAN_EVENT_FIELDS);
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
if (eventType) assertKnownEventType(eventType);
|
||||
const basePath = `/api/sessions/${sessionId}/events`;
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
@@ -432,7 +430,11 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// event partition, so a page can be empty while matches sit on a later page.
|
||||
// Auto-exhaust forward so the model isn't misled by an empty-but-continuable page;
|
||||
// a timeout is retried once with a halved pageSize on the same cursor.
|
||||
const data = await scanWithTimeoutFallback(path, basePath, effectivePageSize(pageSize, continuation));
|
||||
const data = withEventTypeNote(
|
||||
await scanWithTimeoutFallback(path, basePath, effectivePageSize(pageSize, continuation)),
|
||||
eventType,
|
||||
!continuation && !severity && !source,
|
||||
);
|
||||
// Announce the omission in-band so a reader of the result knows the payload exists and how to get it.
|
||||
return toolResultText(leanDefaultApplied ? { ...data, ...LEAN_EVENT_OMISSION } : data, MAX_RESULT_SIZE_CHARS.events);
|
||||
} catch (error: unknown) {
|
||||
@@ -807,7 +809,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'are EXPECTED (>= expectedHitRate on the baseline); and the open drift alerts (an expected pattern that ' +
|
||||
'matched in none of >= minCandidateSessions sessions on a newer version — Microsoft probably changed the log ' +
|
||||
'wording). On an alert: search_sessions with imeAgentVersion=<version> -> get_session_diagnostics on a session ' +
|
||||
'with a package -> validate the pattern against the real IME log -> compare with the IME decompile -> fix it ' +
|
||||
'with a package -> validate the pattern against the real IME log -> fix it ' +
|
||||
'in rules/ime-log-patterns. Only sessions that reached a terminal run report a histogram (crashes/kills are ' +
|
||||
'excluded from the denominator). catalog says where the shipped-pattern list comes from (the last GitHub ' +
|
||||
'reseed or the deployed backend build) and when it was written.',
|
||||
|
||||
@@ -12,7 +12,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public static class DeadlineNames
|
||||
{
|
||||
/// <summary>Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s).</summary>
|
||||
/// <summary>
|
||||
/// Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s built in; a tenant
|
||||
/// can extend it to 3600 s via <c>HelloWaitTimeoutSeconds</c>).
|
||||
/// </summary>
|
||||
public const string HelloSafety = "hello_safety";
|
||||
|
||||
/// <summary>Brief settle window after ESP exit before we emit completion.</summary>
|
||||
@@ -190,6 +193,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string IsCloudPc = "isCloudPc";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: the tenant's Hello wait in seconds (remote config
|
||||
/// <c>HelloWaitTimeoutSeconds</c>, stamped by the agent — NOT a registry fact). Only
|
||||
/// stamped when it extends the built-in 300 s <see cref="DeadlineNames.HelloSafety"/>
|
||||
/// window; missing → the built-in window.
|
||||
/// </summary>
|
||||
public const string HelloWaitTimeoutSeconds = "helloWaitTimeoutSeconds";
|
||||
|
||||
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
||||
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
||||
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
||||
|
||||
@@ -10,9 +10,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Classic UserDriven-v1 enrollment handlers. Plan §2.5 partial-class layout.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Post-ESP Hello-safety grace period per plan §2.7.
|
||||
private static readonly TimeSpan s_helloSafetyWindow = TimeSpan.FromSeconds(300);
|
||||
|
||||
// Grace window between both-prerequisites-resolved and Completed. Plan §5 Fix 6.
|
||||
// Short enough that the user doesn't notice; long enough for the reducer's
|
||||
// phase_transition(FinalizingSetup) + enrollment_complete effects to reach the
|
||||
@@ -267,18 +264,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
: null);
|
||||
}
|
||||
|
||||
// Replay-safety: floor the 300-s Hello-safety window at AgentBootUtc so a replayed
|
||||
// EspExiting signal from CMTrace doesn't fire HelloSafety immediately at boot
|
||||
// (which would mark Hello as Timeout the moment the agent reads the log tail).
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
@@ -376,6 +362,89 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(newState, transition, AppendEffect(effects, waitingEffect));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Post-ESP Hello-safety window (plan §2.7): the built-in 300 s, or the tenant's longer
|
||||
/// <c>HelloWaitTimeoutSeconds</c> once the agent stamped it
|
||||
/// (<see cref="EnrollmentScenarioObservations.HelloWaitTimeoutSeconds"/>), clamped to
|
||||
/// <see cref="HelloWaitTimeout.MaxSeconds"/>.
|
||||
/// </summary>
|
||||
internal static TimeSpan HelloSafetyWindow(EnrollmentScenarioObservations observations) =>
|
||||
TimeSpan.FromSeconds(HelloWaitTimeout.EffectiveSeconds(
|
||||
observations.HelloWaitTimeoutSeconds?.Value ?? HelloWaitTimeout.BuiltInSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="DeadlineNames.HelloSafety"/> deadline every arm site schedules:
|
||||
/// <see cref="HelloSafetyWindow"/> after the signal, floored at the agent boot
|
||||
/// (<see cref="EffectiveDeadlineBase"/>) so a replayed EspExiting from CMTrace doesn't
|
||||
/// fire HelloSafety immediately at boot (which would mark Hello as Timeout the moment
|
||||
/// the agent reads the log tail).
|
||||
/// </summary>
|
||||
private static ActiveDeadline BuildHelloSafetyDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(HelloSafetyWindow(state.ScenarioObservations)),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// The tenant window arrived after <see cref="DeadlineNames.HelloSafety"/> was armed —
|
||||
/// necessarily with the built-in window, the fact being set-once. Moves the armed
|
||||
/// deadline out by the difference so the tenant's window counts from the original
|
||||
/// anchor, and returns the re-schedule effect (the live scheduler replaces a timer of
|
||||
/// the same name). <c>null</c> when nothing changes: the fact was already known, the
|
||||
/// window is not longer, no deadline is armed, or it had expired at this signal —
|
||||
/// then its pending fire decides.
|
||||
/// </summary>
|
||||
private static DecisionEffect? ExtendArmedHelloSafetyToTenantWindow(
|
||||
DecisionState before, DecisionStateBuilder builder, DecisionSignal signal)
|
||||
{
|
||||
if (before.ScenarioObservations.HelloWaitTimeoutSeconds != null)
|
||||
return null;
|
||||
|
||||
var extension = HelloSafetyWindow(builder.ScenarioObservations) - HelloSafetyWindow(before.ScenarioObservations);
|
||||
if (extension <= TimeSpan.Zero)
|
||||
return null;
|
||||
|
||||
foreach (var armed in before.Deadlines)
|
||||
{
|
||||
if (armed.Name != DeadlineNames.HelloSafety)
|
||||
continue;
|
||||
if (armed.DueAtUtc <= signal.OccurredAtUtc)
|
||||
return null;
|
||||
|
||||
var extended = new ActiveDeadline(
|
||||
armed.Name, armed.DueAtUtc.Add(extension), armed.FiresSignalKind, armed.FiresPayload);
|
||||
builder.AddDeadline(extended);
|
||||
return new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: extended);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> while a tenant-extended Hello wait is running: the tenant window is
|
||||
/// recorded and a Hello resolution deadline (<see cref="DeadlineNames.HelloSafety"/>, or
|
||||
/// the Device Preparation backstop that stands in for it) is armed and not yet due at
|
||||
/// <paramref name="nowUtc"/>. The session is then waiting on a bounded window (up to
|
||||
/// <see cref="HelloWaitTimeout.MaxSeconds"/>) that resolves it either way — not stalled.
|
||||
/// Always <c>false</c> on the built-in window, so default tenants keep their stall
|
||||
/// reporting unchanged. Read by the agent's stall probe.
|
||||
/// </summary>
|
||||
public static bool IsTenantHelloWaitPending(DecisionState? state, DateTime nowUtc)
|
||||
{
|
||||
if (state?.ScenarioObservations.HelloWaitTimeoutSeconds == null)
|
||||
return false;
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if ((d.Name == DeadlineNames.HelloSafety || d.Name == DeadlineNames.DevicePrepCompletion)
|
||||
&& d.DueAtUtc > nowUtc)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 8b8d611d fix (2026-05-20): emit a <see cref="DecisionEffectKind.CancelDeadline"/>
|
||||
/// effect for <see cref="DeadlineNames.HelloSafety"/> when it is actually armed in
|
||||
@@ -469,15 +538,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.CancelDeadline(DeadlineNames.FinalizingGrace);
|
||||
}
|
||||
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
.AddDeadline(helloSafety);
|
||||
@@ -524,7 +585,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// finished AccountSetup) OR <see cref="EnrollmentScenarioObservations.SkipUserEsp"/>
|
||||
/// is <c>true</c> (no User-ESP page on this flow) — synthesise
|
||||
/// <c>HelloOutcome="Skipped"</c> here and route directly through Finalizing.
|
||||
/// This avoids waiting out the full 5-min HelloSafety window when the policy reader
|
||||
/// This avoids waiting out the full HelloSafety window when the policy reader
|
||||
/// already told us no Hello wizard is expected. Belt-and-suspenders alongside the
|
||||
/// EspExiting → HelloSafety reducer path: the path that fires first wins.
|
||||
/// </para>
|
||||
@@ -842,15 +903,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// AgentBootUtc for replay safety) — mirror of the deferred-promote tail in
|
||||
// HandleAccountSetupProvisioningCompleteV1. A real HelloResolved then completes
|
||||
// normally; a no-show resolves via HelloSafety's synthetic outcome.
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
+18
-6
@@ -20,13 +20,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <summary>
|
||||
/// Resolution window from real-user desktop arrival on a Device Preparation session.
|
||||
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
|
||||
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
|
||||
/// through a configured Hello wizard, and the tracker's own Hello completion
|
||||
/// timers normally resolve long before this fires — it is the last net, not the
|
||||
/// expected path.
|
||||
/// not the built-in <c>HelloSafety</c> window (300 s): the user may legitimately still
|
||||
/// be walking through a configured Hello wizard, and with the built-in budget the
|
||||
/// tracker's own Hello completion timer normally resolves long before this fires — it
|
||||
/// is the last net, not the expected path. A tenant-extended Hello wait lengthens it
|
||||
/// (see <see cref="BuildDevicePrepCompletionDeadline"/>).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
private static TimeSpan DevicePrepCompletionWindow(EnrollmentScenarioObservations observations)
|
||||
{
|
||||
var helloWindow = HelloSafetyWindow(observations);
|
||||
return helloWindow > s_devicePrepCompletionWindow ? helloWindow : s_devicePrepCompletionWindow;
|
||||
}
|
||||
|
||||
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
|
||||
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
|
||||
{
|
||||
@@ -39,12 +46,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
/// <summary>
|
||||
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
|
||||
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
|
||||
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc). Never
|
||||
/// shorter than the tenant's Hello window (<see cref="HelloSafetyWindow"/>, up to 60 min),
|
||||
/// so a tenant that allows Hello an hour gets that hour on WDP too. With a window of 30 min
|
||||
/// or more it may resolve before the tracker's timer (it counts from the desktop, the
|
||||
/// tracker from the wizard start). A tenant window that arrives after the backstop was
|
||||
/// armed (agent update mid-session) does not move it.
|
||||
/// </summary>
|
||||
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.DevicePrepCompletion,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(DevicePrepCompletionWindow(state.ScenarioObservations)),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -770,15 +770,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
&& state.HelloPolicyEnabled?.Value == false
|
||||
&& state.HelloWizardStartedUtc != null)
|
||||
{
|
||||
var wizardHelloSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var wizardHelloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: wizardHelloSafetyDueAt,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var wizardHelloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
@@ -886,15 +878,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
&& state.HelloPolicyEnabled == null
|
||||
&& state.HelloWizardStartedUtc == null)
|
||||
{
|
||||
var neverObservedSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var neverObservedSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: neverObservedSafetyDueAt,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var neverObservedSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
@@ -393,6 +393,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
private DecisionStep HandleHelloSafetyDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
// Stale fire of a replaced incarnation (same shape as the RealmJoin guard): the armed
|
||||
// hello_safety is due LATER than the due time this fire belongs to — e.g. the tenant
|
||||
// window arrived and moved the deadline out while the old timer's fire was already
|
||||
// queued. Deciding on it would time Hello out at the replaced, shorter window.
|
||||
foreach (var armed in state.Deadlines)
|
||||
{
|
||||
if (armed.Name == DeadlineNames.HelloSafety && armed.DueAtUtc > DeadlineDueAtUtc(signal))
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
return new DecisionStep(
|
||||
bookkept,
|
||||
BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: bookkept.StepIndex,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}",
|
||||
deadEndReason: "hello_safety_stale_superseded_by_rearm"),
|
||||
Array.Empty<DecisionEffect>());
|
||||
}
|
||||
}
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
@@ -523,6 +544,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// the handler delegates the merge logic and only carries out the standard
|
||||
/// step bookkeeping + transition.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one effect it can emit: a <see cref="DecisionEffectKind.ScheduleDeadline"/> that
|
||||
/// moves an already armed <see cref="DeadlineNames.HelloSafety"/> out to the tenant's
|
||||
/// Hello window when that fact arrives late (see
|
||||
/// <see cref="ExtendArmedHelloSafetyToTenantWindow"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleEnrollmentFactsObservedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
@@ -559,6 +586,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
.WithCloudPc(isCloudPc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
// Tenant Hello wait (remote config, not a registry fact) — widens the hello_safety
|
||||
// window. Set-once, and only a value that actually extends the built-in window counts
|
||||
// (the agent never stamps anything else). This signal is posted right after agent
|
||||
// start, but a backfilled ESP exit can arm hello_safety first; that deadline is moved
|
||||
// out to the tenant window.
|
||||
DecisionEffect? helloSafetyExtendEffect = null;
|
||||
if (signal.Payload != null
|
||||
&& signal.Payload.TryGetValue(SignalPayloadKeys.HelloWaitTimeoutSeconds, out var rawHelloWait)
|
||||
&& int.TryParse(rawHelloWait, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var helloWaitSeconds)
|
||||
&& helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
|
||||
{
|
||||
builder.ScenarioObservations = builder.ScenarioObservations
|
||||
.WithHelloWaitTimeoutSeconds(helloWaitSeconds, signal.SessionSignalOrdinal);
|
||||
helloSafetyExtendEffect = ExtendArmedHelloSafetyToTenantWindow(state, builder, signal);
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
@@ -567,7 +610,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.EnrollmentFactsObserved));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
return new DecisionStep(
|
||||
newState,
|
||||
transition,
|
||||
helloSafetyExtendEffect != null ? new[] { helloSafetyExtendEffect } : Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -959,15 +1005,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
|
||||
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
|
||||
// the replay-safety guarantee.
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
@@ -177,6 +177,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
seen.Add("cloud_pc_marker");
|
||||
evidence["cloudPcMarker"] = OrdinalEvidence(obs.CloudPc.SourceSignalOrdinal);
|
||||
}
|
||||
// Tenant-extended Hello window — surfaced so a terminal audit trail explains a Hello
|
||||
// timeout (or AwaitingHello dwell) longer than the built-in 5 minutes.
|
||||
if (obs.HelloWaitTimeoutSeconds != null)
|
||||
{
|
||||
seen.Add("hello_wait_extended");
|
||||
evidence["helloWaitExtended"] = new Dictionary<string, object>(capacity: 2, StringComparer.Ordinal)
|
||||
{
|
||||
["ordinal"] = obs.HelloWaitTimeoutSeconds.SourceSignalOrdinal,
|
||||
["seconds"] = obs.HelloWaitTimeoutSeconds.Value,
|
||||
};
|
||||
}
|
||||
|
||||
return new SignalCensusResult(seen, timestamps, evidence);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? CloudPc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-config fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
|
||||
/// (payload <c>helloWaitTimeoutSeconds</c>, stamped by the agent from
|
||||
/// <c>RemoteConfig.HelloWaitTimeoutSeconds</c> only when it exceeds the built-in 300 s):
|
||||
/// the <c>hello_safety</c> window the engine arms instead of the built-in one.
|
||||
/// <c>null</c> = built-in window. Set-once.
|
||||
/// </summary>
|
||||
public SignalFact<int>? HelloWaitTimeoutSeconds { get; init; }
|
||||
|
||||
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
|
||||
ShellCoreWhiteGloveSuccessSeen != null
|
||||
? this
|
||||
@@ -168,5 +177,10 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
CloudPc != null
|
||||
? this
|
||||
: this with { CloudPc = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithHelloWaitTimeoutSeconds(int value, long sourceSignalOrdinal) =>
|
||||
HelloWaitTimeoutSeconds != null
|
||||
? this
|
||||
: this with { HelloWaitTimeoutSeconds = new SignalFact<int>(value, sourceSignalOrdinal) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") ---
|
||||
/// <summary>Creates a group (meta row) and returns the generated groupId.</summary>
|
||||
Task<string> CreateTenantGroupAsync(string name, string createdBy);
|
||||
/// <summary>Renames a group (meta row). Returns false if the group does not exist.</summary>
|
||||
Task<bool> RenameTenantGroupAsync(string groupId, string name);
|
||||
/// <summary>
|
||||
/// Updates a group's meta row: <paramref name="name"/> and <paramref name="customerLabel"/> are each left
|
||||
/// unchanged when null; an empty label clears it. Returns false if the group does not exist.
|
||||
/// </summary>
|
||||
Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel);
|
||||
/// <summary>Deletes a group: all rows in its partition (meta + membership) AND every UPN
|
||||
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
|
||||
Task<bool> DeleteTenantGroupAsync(string groupId);
|
||||
@@ -153,6 +156,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public List<TenantGroupAssignment> Assignees { get; set; } = new();
|
||||
/// <summary>The managing tenant that owns this self-service group (<c>msp-{tenantId}</c>); null for operator-created groups.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
/// <summary>Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -588,11 +588,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Body of PATCH global/tenant-groups/{groupId} — a rename.</summary>
|
||||
/// <summary>Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required.</summary>
|
||||
public class UpdateTenantGroupRequest : IApiRequest
|
||||
{
|
||||
/// <summary>New display name.</summary>
|
||||
/// <summary>New display name (Global Admin UI only).</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body of POST global/tenant-groups/{groupId}/tenants.</summary>
|
||||
|
||||
@@ -317,10 +317,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int AgentSelfMetricsIntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit.
|
||||
/// Default: 30 seconds
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
|
||||
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
|
||||
/// Default: 300 seconds
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Bounds of the tenant setting <see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/> —
|
||||
/// how long after the Enrollment Status Page closes the agent waits for Windows Hello for
|
||||
/// Business (wizard start plus setup) before it records Hello as timed out. Single source for
|
||||
/// the backend validation, the agent's Hello tracker and the decision engine's
|
||||
/// <c>hello_safety</c> deadline; the web settings input mirrors the configurable bounds.
|
||||
/// </summary>
|
||||
public static class HelloWaitTimeout
|
||||
{
|
||||
/// <summary>Smallest configurable value.</summary>
|
||||
public const int MinSeconds = 30;
|
||||
|
||||
/// <summary>Largest configurable value (1 hour) — also the ceiling of the effective wait.</summary>
|
||||
public const int MaxSeconds = 3600;
|
||||
|
||||
/// <summary>
|
||||
/// The engine's built-in Hello window (5 minutes). Configured values up to it keep it —
|
||||
/// the old 30 s default is still stored for many tenants — only larger values extend it.
|
||||
/// </summary>
|
||||
public const int BuiltInSeconds = 300;
|
||||
|
||||
/// <summary>Default for tenants that never changed the setting: the built-in window.</summary>
|
||||
public const int DefaultSeconds = BuiltInSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// The wait a configured value stands for: clamped into
|
||||
/// [<see cref="BuiltInSeconds"/>, <see cref="MaxSeconds"/>].
|
||||
/// </summary>
|
||||
public static int EffectiveSeconds(int configuredSeconds) =>
|
||||
configuredSeconds < BuiltInSeconds ? BuiltInSeconds
|
||||
: configuredSeconds > MaxSeconds ? MaxSeconds
|
||||
: configuredSeconds;
|
||||
}
|
||||
}
|
||||
@@ -396,10 +396,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int PerformanceCollectorIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit
|
||||
/// Default: 30 seconds
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
|
||||
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
|
||||
/// Default: 300 seconds
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
// ===== AGENT AUTH CIRCUIT BREAKER =====
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
public string? OwnerDomain { get; set; }
|
||||
/// <summary>The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer.</summary>
|
||||
/// <summary>The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer.</summary>
|
||||
public string Name { get; set; } = default!;
|
||||
/// <summary>self-service | operator</summary>
|
||||
public string Source { get; set; } = default!;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <c>EventIngestProcessor</c> when an <c>ime_agent_version</c> event carries a version
|
||||
/// the platform has never seen before (RecordImeVersionAsync insert succeeded). The
|
||||
/// worker downloads the IME installer and archives it into the <c>ime-archive</c> blob
|
||||
/// container so every fleet-observed IME build stays available for later decompilation
|
||||
/// container so every fleet-observed IME build stays available for later analysis
|
||||
/// and build-to-build diffing, even after Microsoft's versionless CDN URL has moved on.
|
||||
/// </summary>
|
||||
public sealed class ImeMsiArchiveEnvelope
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user