mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(security): gather-rule on_event feedback loop — timeline feed carries the event source and never dispatches on_event for gather-emitted events; per-rule on_event cap (100/session); backend rejects rules triggering on their own output type / gather_result
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-29 (11)
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — `on_event` feedback-loop guard: `TimelineEventStream` publishes the event source and `GatherRuleExecutorHost` never dispatches `on_event` for gather-emitted events (a rule triggering on its own output type looped for the whole session); per-rule on_event execution cap (100/session); backend rejects `on_event` rules whose trigger equals their output type or `gather_result`.
|
||||
|
||||
## 2026-08-29 (10)
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
|
||||
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- backend
|
||||
- gather-rules
|
||||
- config
|
||||
timestamp: 2026-08-04T00:00:00+02:00
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -89,6 +89,18 @@ Consequences of the emitted-event feed:
|
||||
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
|
||||
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
|
||||
emitted `EnrollmentEvent`).
|
||||
* **Feedback-loop guard** (2026-08-29): gather output travels the same post → ingress →
|
||||
reducer → emitter → stream path, so the stream would hand the executor's own events straight
|
||||
back to `OnEvent`. A rule with `triggerEventType == outputEventType` (or two rules chained
|
||||
A→B→A) then executed and emitted without bound for the whole session — `emitMode:
|
||||
on_change` does not help when the collected result varies. `TimelineEventStream` therefore
|
||||
publishes the event **source**, and `GatherRuleExecutorHost` never dispatches `on_event` for
|
||||
`Source == GatherRuleExecutor.SourceName` (phase declarations on such events are still
|
||||
honoured). Second layer: `GatherRuleExecutor.MaxOnEventExecutionsPerRule` (100 per rule and
|
||||
session) bounds any cycle that reaches the executor through a non-gather event; the boundary
|
||||
is logged once (agent log Warning + debug-trace `trigger` line). Backend validation rejects
|
||||
`on_event` rules whose trigger equals their own output type or `gather_result` — such a rule
|
||||
could never fire.
|
||||
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
|
||||
off the raw signal stream deliberately — their collections are about OS state at the raw
|
||||
boundary (ESP exit, keep-awake), not about timeline agreement.
|
||||
@@ -103,7 +115,8 @@ Consequences of the emitted-event feed:
|
||||
`_startupRulesExecuted` dedup; it is not part of the `WaitForStartupRules` latch).
|
||||
* `phase_change` — evaluated against the **new** phase; the scope gate runs before the
|
||||
per-(rule, phase) dedup so an out-of-scope pass does not consume the execution slot.
|
||||
* `on_event` — gated by the current phase.
|
||||
* `on_event` — gated by the current phase; never fires on gather output; capped at
|
||||
`MaxOnEventExecutionsPerRule` per rule and session.
|
||||
* **Before the first phase signal** of a session (`_currentPhase == Unknown`) scoped rules are
|
||||
inactive. Sessions that never produce phase signals never run them (documented behavior).
|
||||
* **From-phase latch**: activates when `phase != Unknown && phase != Failed && (int)phase >=
|
||||
@@ -164,4 +177,4 @@ Consequences of the emitted-event feed:
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests`, `GatherRuleExecutorOnEventCapTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
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 Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// on_event has no once-per-(rule, phase) dedup like the phase triggers. The per-rule
|
||||
/// execution cap (<see cref="GatherRuleExecutor.MaxOnEventExecutionsPerRule"/>) bounds any
|
||||
/// trigger cycle that survives the host's source guard (e.g. two rules chained through an
|
||||
/// intermediate non-gather event) so a mis-authored rule can never spin for a whole session.
|
||||
/// Executions are observed through the emitted events; the rule uses a registry path that
|
||||
/// exists on every Windows box so each execution emits exactly one event.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorOnEventCapTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _gate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
public GatherRuleExecutorOnEventCapTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor("sess", "tenant",
|
||||
evt => { lock (_gate) _events.Add(evt); }, logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private int EventCount { get { lock (_gate) return _events.Count; } }
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 20000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (EventCount >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return EventCount >= count;
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string id, string triggerEventType) => new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = "gather_cap_test",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OnEvent_ExecutesAtMostMaxOnEventExecutionsPerRule_PerSession()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { OnEventRule("GATHER-CAP-001", "ext_event") });
|
||||
|
||||
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
|
||||
for (var i = 0; i < cap + 25; i++)
|
||||
_executor.OnEvent("ext_event");
|
||||
|
||||
Assert.True(WaitForEventCount(cap), $"expected {cap} executions, got {EventCount}");
|
||||
Thread.Sleep(500);
|
||||
Assert.Equal(cap, EventCount);
|
||||
|
||||
// Further triggers stay ignored for the rest of the session.
|
||||
_executor.OnEvent("ext_event");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(cap, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventCap_IsPerRule_NotGlobal()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
OnEventRule("GATHER-CAP-002", "ext_event"),
|
||||
OnEventRule("GATHER-CAP-003", "ext_event"),
|
||||
});
|
||||
|
||||
var cap = GatherRuleExecutor.MaxOnEventExecutionsPerRule;
|
||||
for (var i = 0; i < cap + 5; i++)
|
||||
_executor.OnEvent("ext_event");
|
||||
|
||||
Assert.True(WaitForEventCount(2 * cap), $"expected {2 * cap} executions, got {EventCount}");
|
||||
Thread.Sleep(500);
|
||||
Assert.Equal(2 * cap, EventCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-10
@@ -122,13 +122,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
|
||||
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
|
||||
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown, "Engine");
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// Only the engine's actual phase declaration fires the rule.
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
|
||||
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -151,14 +151,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
|
||||
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 2));
|
||||
}
|
||||
|
||||
@@ -167,11 +167,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventRule_NeverFiresOnGatherExecutorOutput_BreaksSelfTriggerLoop()
|
||||
{
|
||||
// A rule whose TriggerEventType equals its own OutputEventType: the event it emits
|
||||
// travels post → ingress → reducer → emitter → stream and comes back here with
|
||||
// Source == GatherRuleExecutor. Without the source guard that re-enters OnEvent and
|
||||
// the rule loops for the whole session (execute → emit → trigger → execute …).
|
||||
BuildHost(Rule("GATHER-HOST-006", "on_event", triggerEventType: "gather_host_test"));
|
||||
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown,
|
||||
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// The same event type from any other origin still triggers exactly once per event.
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.Unknown, "Engine");
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseDeclarationOnGatherSourcedEvent_StillDrivesPhaseTriggers()
|
||||
{
|
||||
// The source guard only suppresses on_event dispatch; a phase declaration is a
|
||||
// timeline fact regardless of which component carried it.
|
||||
BuildHost(Rule("GATHER-HOST-007", "phase_change", triggerPhase: "DeviceSetup"));
|
||||
|
||||
_stream.Publish("gather_host_test", EnrollmentPhase.DeviceSetup,
|
||||
Core.Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
@@ -181,7 +214,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_host!.Stop();
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown, "Engine");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
|
||||
+3
-3
@@ -161,7 +161,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var published = new List<(string EventType, EnrollmentPhase Phase)>();
|
||||
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
|
||||
stream.EventEmitted += (eventType, phase, _) => published.Add((eventType, phase));
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
@@ -190,8 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var secondSubscriberCalls = 0;
|
||||
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
|
||||
stream.EventEmitted += (_, _, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _, _) => secondSubscriberCalls++;
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SecurityWarning,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+2
-2
@@ -291,7 +291,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SecurityWarning,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
+38
-1
@@ -21,6 +21,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// </summary>
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="EnrollmentEvent.Source"/> label stamped on every event the gather pipeline
|
||||
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
|
||||
/// gather output from re-triggering on_event rules.
|
||||
/// </summary>
|
||||
public const string SourceName = "GatherRuleExecutor";
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on on_event executions per rule and session. on_event has no natural
|
||||
/// once-per-(rule, phase) dedup like the phase triggers; this cap bounds any remaining
|
||||
/// cycle (e.g. two rules chained through an intermediate non-gather event) and any
|
||||
/// pathological trigger type that fires on every timeline entry.
|
||||
/// </summary>
|
||||
internal const int MaxOnEventExecutionsPerRule = 100;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleContext _context;
|
||||
@@ -30,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
|
||||
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
|
||||
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
|
||||
private readonly Dictionary<string, int> _onEventExecutions = new Dictionary<string, int>();
|
||||
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
|
||||
|
||||
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
|
||||
@@ -394,6 +410,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-rule execution cap (see MaxOnEventExecutionsPerRule). The counter is
|
||||
// bumped once past the cap so the boundary is logged exactly once.
|
||||
int executions;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
_onEventExecutions.TryGetValue(rule.RuleId, out executions);
|
||||
if (executions <= MaxOnEventExecutionsPerRule)
|
||||
_onEventExecutions[rule.RuleId] = executions + 1;
|
||||
}
|
||||
|
||||
if (executions >= MaxOnEventExecutionsPerRule)
|
||||
{
|
||||
if (executions == MaxOnEventExecutionsPerRule)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} reached the on_event execution cap ({MaxOnEventExecutionsPerRule}) for this session — further {eventType} triggers are ignored");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"on_event {eventType} ignored: per-rule execution cap ({MaxOnEventExecutionsPerRule}) reached for this session");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
@@ -482,7 +519,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Source = SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
|
||||
+13
-3
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
|
||||
// then phase/event triggers degrade off (startup + interval rules still run).
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
private Action<string, EnrollmentPhase>? _timelineHandler;
|
||||
private Action<string, EnrollmentPhase, string>? _timelineHandler;
|
||||
private readonly object _sync = new object();
|
||||
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
|
||||
|
||||
@@ -101,8 +101,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
|
||||
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
|
||||
/// effect path; it also dedups phase rules per (rule, phase).
|
||||
/// <para>
|
||||
/// Feedback-loop guard: events the gather executor emitted itself (Source ==
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName"/>) travel the
|
||||
/// same post → ingress → reducer → emitter → stream path and would otherwise re-enter
|
||||
/// <c>OnEvent</c>. A rule whose TriggerEventType equals its own OutputEventType (or two
|
||||
/// rules chained A→B→A) then executes and emits without bound for the whole session.
|
||||
/// Gather output therefore never dispatches on_event rules; phase declarations are still
|
||||
/// honoured (gather events carry Phase=Unknown, so this is a no-op in practice).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -114,7 +123,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor.OnPhaseChanged(phase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
if (!string.IsNullOrEmpty(eventType)
|
||||
&& !string.Equals(source, Monitoring.Telemetry.Gather.GatherRuleExecutor.SourceName, StringComparison.Ordinal))
|
||||
{
|
||||
_executor.OnEvent(eventType);
|
||||
}
|
||||
|
||||
@@ -33,18 +33,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// which is exactly the set the UI timeline groups on), plus the originating
|
||||
/// <see cref="EnrollmentEvent.Source"/> component label. The source lets subscribers that
|
||||
/// themselves emit timeline events (the gather-rule executor) recognise their own output
|
||||
/// and not re-trigger on it — see <see cref="GatherRuleExecutorHost"/>.
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
public event Action<string, EnrollmentPhase, string>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
internal void Publish(string eventType, EnrollmentPhase phase, string source)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
foreach (Action<string, EnrollmentPhase, string> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
try { subscriber(eventType, phase, source); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
|
||||
@@ -148,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase, evt.Source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -240,6 +240,45 @@ public class GatherRuleScopeFieldsTests
|
||||
ValidationRule(activePhases: new List<string> { "" })));
|
||||
}
|
||||
|
||||
private static GatherRule OnEventRule(string triggerEventType, string? outputEventType)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-VAL-EVT",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Contoso",
|
||||
Trigger = "on_event",
|
||||
TriggerEventType = triggerEventType,
|
||||
OutputEventType = outputEventType!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("gather_proxy", "gather_proxy")] // self-cycle
|
||||
[InlineData("GATHER_PROXY", "gather_proxy")] // case-insensitive (agent matches OrdinalIgnoreCase)
|
||||
[InlineData("gather_result", null)] // self-cycle through the default output type
|
||||
[InlineData("gather_result", "")]
|
||||
[InlineData("gather_result", "gather_other")] // gather output namespace never triggers
|
||||
public void Validate_RejectsOnEventRuleTriggeringOnGatherOutput(string trigger, string? output)
|
||||
{
|
||||
Assert.NotNull(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_complete", "gather_proxy")]
|
||||
[InlineData("app_install_failed", null)]
|
||||
[InlineData("", "gather_proxy")] // partial/toggle payload passes through
|
||||
public void Validate_AcceptsOnEventRuleOnNonGatherEvents(string trigger, string? output)
|
||||
{
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(OnEventRule(trigger, output)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OutputEqualsTrigger_IsOnlyCheckedForOnEventTrigger()
|
||||
{
|
||||
var rule = OnEventRule("gather_same", "gather_same");
|
||||
rule.Trigger = "startup";
|
||||
Assert.Null(GatherRulesFunction.ValidateScopeAndEmitMode(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsBothScopeFieldsSet()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -167,12 +168,33 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the phase-scope + emit-mode fields on create/update.
|
||||
/// Returns an error message for a 400 response, or null when valid.
|
||||
/// Validates the phase-scope + emit-mode fields and the on_event trigger wiring on
|
||||
/// create/update. Returns an error message for a 400 response, or null when valid.
|
||||
/// Toggle-style partial payloads carry none of these fields and pass through.
|
||||
/// <para>
|
||||
/// on_event: the agent never dispatches on_event rules for events the gather executor
|
||||
/// itself emitted (feedback-loop guard, <c>GatherRuleExecutorHost</c>), so a rule that
|
||||
/// triggers on its own output — or on the <c>gather_result</c> default output type —
|
||||
/// could never fire and is rejected here with an explanatory message instead of being
|
||||
/// stored as a dead rule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateScopeAndEmitMode(GatherRule rule)
|
||||
{
|
||||
if (string.Equals(rule.Trigger, "on_event", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(rule.TriggerEventType))
|
||||
{
|
||||
var outputEventType = string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? Constants.EventTypes.GatherResult
|
||||
: rule.OutputEventType;
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, outputEventType, StringComparison.OrdinalIgnoreCase))
|
||||
return "triggerEventType must differ from the rule's own outputEventType — gather rules never trigger on their own output.";
|
||||
|
||||
if (string.Equals(rule.TriggerEventType, Constants.EventTypes.GatherResult, StringComparison.OrdinalIgnoreCase))
|
||||
return $"triggerEventType '{Constants.EventTypes.GatherResult}' is the gather-rule output type — gather rules never trigger on gather output.";
|
||||
}
|
||||
|
||||
var hasActivePhases = rule.ActivePhases != null && rule.ActivePhases.Count > 0;
|
||||
var hasFromPhase = !string.IsNullOrEmpty(rule.ActiveFromPhase);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user