mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(agent-v2): M4.5.b runtime entry + DefaultComponentFactory
Wires the V2 agent into a runnable state: - TenantIdResolver: HKLM\SOFTWARE\Microsoft\Enrollments → AADTenantID (EnrollmentType=6) - SessionIdPersistence: atomic session.id via File.Replace - IComponentFactory signature: +ISignalIngressSink +IClock (bridges the sink/clock access into factory-built adapters) - DefaultComponentFactory: 5 kernel hosts (EspAndHelloTracker coordinator, DesktopArrivalDetector, AadJoinWatcher, ImeLogTracker+ImeProcessWatcher, StallProbeCollector) + 2 peripheral hosts (Performance, AgentSelfMetrics) conditional on CollectorConfiguration toggles - Program.cs: full runtime entry (args → Logger → TenantId/SessionId → AwaitEnrollment → BackendApiClient+RemoteConfigService → MtlsHttpClientFactory → BackendTelemetryUploader → DefaultComponentFactory → EnrollmentOrchestrator → Ctrl+C wait → Stop → exit 0) Production host choice: EspAndHelloTracker as single coordinator entry (instead of 4 sub-tracker hosts in parallel) — avoids adapter signal duplication documented in M4.3 tech-debt. StallProbeHost drives its own 60s idle-tick timer (Legacy MonitoringService.IdleCheckCallback is gone). Tests: +8 SessionIdPersistenceTests (atomic-write, round-trip, corrupt- recovery, regeneration, multi-instance resume). V2.Core 287→295, total 1424→1432 green. Known peripheral gaps (tracked for follow-up M4.6): install mode, SelfUpdater, ServerActionDispatcher, CleanupService, Geo/NTP/Timezone wiring, Emergency/DistressReporter wiring, AnalyzerManager, SummaryDialog, DiagnosticsPackageService, gather-rules/ime-matching modes, watchdog timers, multi-instance guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -56,15 +57,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
|
||||
public IReadOnlyCollection<string>? CapturedWhiteGloveSealingPatternIds { get; private set; }
|
||||
|
||||
public ISignalIngressSink? CapturedIngress { get; private set; }
|
||||
public IClock? CapturedClock { get; private set; }
|
||||
|
||||
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds)
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock)
|
||||
{
|
||||
CapturedSink = onEnrollmentEvent;
|
||||
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
|
||||
CapturedIngress = ingress;
|
||||
CapturedClock = clock;
|
||||
foreach (var name in HostNames)
|
||||
{
|
||||
Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent));
|
||||
|
||||
+4
-1
@@ -6,6 +6,7 @@ using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -24,7 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds)
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return Array.Empty<ICollectorHost>();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
public sealed class SessionIdPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetOrCreate_without_existing_file_creates_a_new_guid()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new SessionIdPersistence(tmp.Path);
|
||||
|
||||
var id = sut.GetOrCreate();
|
||||
|
||||
Assert.True(Guid.TryParse(id, out _));
|
||||
Assert.True(File.Exists(Path.Combine(tmp.Path, "session.id")));
|
||||
Assert.Equal(id, File.ReadAllText(Path.Combine(tmp.Path, "session.id")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_returns_same_id_on_repeated_calls()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new SessionIdPersistence(tmp.Path);
|
||||
|
||||
var first = sut.GetOrCreate();
|
||||
var second = sut.GetOrCreate();
|
||||
var third = sut.GetOrCreate();
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(first, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_new_instance_resumes_persisted_session()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var first = new SessionIdPersistence(tmp.Path).GetOrCreate();
|
||||
var resumed = new SessionIdPersistence(tmp.Path).GetOrCreate();
|
||||
|
||||
Assert.Equal(first, resumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_regenerates_when_file_content_is_corrupt()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
File.WriteAllText(Path.Combine(tmp.Path, "session.id"), "not-a-guid");
|
||||
|
||||
var id = new SessionIdPersistence(tmp.Path).GetOrCreate();
|
||||
|
||||
Assert.True(Guid.TryParse(id, out _));
|
||||
Assert.NotEqual("not-a-guid", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_clears_persisted_session_so_next_call_regenerates()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new SessionIdPersistence(tmp.Path);
|
||||
var first = sut.GetOrCreate();
|
||||
|
||||
sut.Delete();
|
||||
Assert.False(File.Exists(Path.Combine(tmp.Path, "session.id")));
|
||||
|
||||
var second = sut.GetOrCreate();
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_creates_data_directory_when_missing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var nested = Path.Combine(tmp.Path, "nested", "state");
|
||||
Assert.False(Directory.Exists(nested));
|
||||
|
||||
var sut = new SessionIdPersistence(nested);
|
||||
var id = sut.GetOrCreate();
|
||||
|
||||
Assert.True(Directory.Exists(nested));
|
||||
Assert.True(Guid.TryParse(id, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_null_or_empty_data_directory()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(""));
|
||||
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_with_logger_emits_resume_debug_on_second_call()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logDir = Path.Combine(tmp.Path, "logs");
|
||||
var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
|
||||
|
||||
var first = new SessionIdPersistence(tmp.Path).GetOrCreate(logger);
|
||||
var second = new SessionIdPersistence(tmp.Path).GetOrCreate(logger);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IComponentFactory"/>. Plan §4.x M4.5.b.
|
||||
/// <para>
|
||||
/// Builds the real Collector-Hosts for the V2 agent runtime:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>EspAndHelloHost</b> — <see cref="EspAndHelloTracker"/> coordinator
|
||||
/// (internally aggregates HelloTracker + ShellCoreTracker + ProvisioningStatusTracker
|
||||
/// + ModernDeploymentTracker) wired via <see cref="EspAndHelloTrackerAdapter"/>.
|
||||
/// This is the single production entry for the ESP+Hello signal surface — avoids
|
||||
/// double emission that would happen if the sub-tracker adapters were also wired
|
||||
/// in parallel (§4.x M4.3 tech-debt note about adapter duplication).</item>
|
||||
/// <item><b>DesktopArrivalHost</b> — <see cref="DesktopArrivalDetector"/> + <see cref="DesktopArrivalDetectorAdapter"/>.</item>
|
||||
/// <item><b>AadJoinHost</b> — <see cref="AadJoinWatcher"/> + <see cref="AadJoinWatcherAdapter"/>.</item>
|
||||
/// <item><b>ImeLogHost</b> — <see cref="ImeLogTracker"/> + <see cref="ImeProcessWatcher"/>
|
||||
/// + <see cref="ImeLogTrackerAdapter"/>.</item>
|
||||
/// <item><b>StallProbeHost</b> — <see cref="StallProbeCollector"/> +
|
||||
/// <see cref="StallProbeCollectorAdapter"/>. Owns its 60-s idle-check timer (the
|
||||
/// collector itself has no timer — it's a pure probe invoked from outside).</item>
|
||||
/// </list>
|
||||
/// Optional peripheral hosts (driven by <see cref="CollectorConfiguration"/> toggles):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>PerformanceHost</b> — CPU/memory/disk samples.</item>
|
||||
/// <item><b>AgentSelfMetricsHost</b> — process CPU, memory and HTTP traffic counters.
|
||||
/// Wires into the <see cref="NetworkMetrics"/> instance created by the Program.cs
|
||||
/// <see cref="BackendApiClient"/> (the V2 <see cref="Transport.Telemetry.BackendTelemetryUploader"/>
|
||||
/// has no metrics — accepted M4.5.b tech-debt).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ModernDeployment Events-Bridge</b>: the ModernDeploymentTracker lives inside
|
||||
/// <see cref="EspAndHelloTracker"/>. Its diagnostic log events reach the telemetry spool
|
||||
/// via <c>onEnrollmentEvent</c>, exactly like any other EspAndHello event. No separate
|
||||
/// adapter exists (documented in <see cref="IComponentFactory"/> + verified by the
|
||||
/// <c>Modern_deployment_host_emits_event_bridge_only_no_decision_signal</c> test).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DefaultComponentFactory : IComponentFactory
|
||||
{
|
||||
private readonly AgentConfiguration _agentConfig;
|
||||
private readonly AgentConfigResponse _remoteConfig;
|
||||
private readonly NetworkMetrics? _networkMetrics;
|
||||
private readonly string _agentVersion;
|
||||
private readonly string _stateDirectory;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
NetworkMetrics? networkMetrics,
|
||||
string agentVersion,
|
||||
string stateDirectory)
|
||||
{
|
||||
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
|
||||
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
|
||||
_networkMetrics = networkMetrics;
|
||||
_agentVersion = string.IsNullOrEmpty(agentVersion) ? "unknown" : agentVersion;
|
||||
_stateDirectory = stateDirectory ?? throw new ArgumentNullException(nameof(stateDirectory));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
if (onEnrollmentEvent == null) throw new ArgumentNullException(nameof(onEnrollmentEvent));
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
|
||||
var hosts = new List<ICollectorHost>();
|
||||
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
|
||||
|
||||
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
||||
|
||||
hosts.Add(new EspAndHelloHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
onEnrollmentEvent: onEnrollmentEvent,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
helloWaitTimeoutSeconds: collectors.HelloWaitTimeoutSeconds,
|
||||
modernDeploymentWatcherEnabled: collectors.ModernDeploymentWatcherEnabled,
|
||||
modernDeploymentLogLevelMax: collectors.ModernDeploymentLogLevelMax,
|
||||
modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled,
|
||||
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
||||
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
stateDirectory: _stateDirectory));
|
||||
|
||||
hosts.Add(new DesktopArrivalHost(logger, ingress, clock));
|
||||
|
||||
hosts.Add(new AadJoinHost(logger, ingress, clock));
|
||||
|
||||
hosts.Add(new ImeLogHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
onEnrollmentEvent: onEnrollmentEvent,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
imeMatchLogPath: _agentConfig.ImeMatchLogPath,
|
||||
imePatterns: _remoteConfig.ImeLogPatterns,
|
||||
stateDirectory: _stateDirectory,
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds));
|
||||
|
||||
if (collectors.StallProbeEnabled)
|
||||
{
|
||||
hosts.Add(new StallProbeHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
onEnrollmentEvent: onEnrollmentEvent,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
thresholdsMinutes: collectors.StallProbeThresholdsMinutes,
|
||||
traceIndices: collectors.StallProbeTraceIndices,
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
|
||||
}
|
||||
|
||||
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
|
||||
|
||||
if (collectors.EnablePerformanceCollector)
|
||||
{
|
||||
hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger,
|
||||
collectors.PerformanceIntervalSeconds));
|
||||
}
|
||||
|
||||
if (collectors.EnableAgentSelfMetrics && _networkMetrics != null)
|
||||
{
|
||||
hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger,
|
||||
_networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds));
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Hosts
|
||||
// =====================================================================================
|
||||
|
||||
private sealed class EspAndHelloHost : ICollectorHost
|
||||
{
|
||||
public string Name => "EspAndHelloTracker";
|
||||
|
||||
private readonly EspAndHelloTracker _tracker;
|
||||
private readonly EspAndHelloTrackerAdapter _adapter;
|
||||
private int _disposed;
|
||||
|
||||
public EspAndHelloHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
int helloWaitTimeoutSeconds,
|
||||
bool modernDeploymentWatcherEnabled,
|
||||
int modernDeploymentLogLevelMax,
|
||||
bool modernDeploymentBackfillEnabled,
|
||||
int modernDeploymentBackfillLookbackMinutes,
|
||||
int[]? modernDeploymentHarmlessEventIds,
|
||||
string stateDirectory)
|
||||
{
|
||||
_tracker = new EspAndHelloTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
onEventCollected: onEnrollmentEvent,
|
||||
logger: logger,
|
||||
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds,
|
||||
modernDeploymentWatcherEnabled: modernDeploymentWatcherEnabled,
|
||||
modernDeploymentLogLevelMax: modernDeploymentLogLevelMax,
|
||||
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
|
||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds);
|
||||
|
||||
_adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock);
|
||||
}
|
||||
|
||||
public void Start() => _tracker.Start();
|
||||
public void Stop() => _tracker.Stop();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _tracker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DesktopArrivalHost : ICollectorHost
|
||||
{
|
||||
public string Name => "DesktopArrivalDetector";
|
||||
|
||||
private readonly DesktopArrivalDetector _detector;
|
||||
private readonly DesktopArrivalDetectorAdapter _adapter;
|
||||
private int _disposed;
|
||||
|
||||
public DesktopArrivalHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock)
|
||||
{
|
||||
_detector = new DesktopArrivalDetector(logger);
|
||||
_adapter = new DesktopArrivalDetectorAdapter(_detector, ingress, clock);
|
||||
}
|
||||
|
||||
public void Start() => _detector.Start();
|
||||
public void Stop() => _detector.Stop();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _detector.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AadJoinHost : ICollectorHost
|
||||
{
|
||||
public string Name => "AadJoinWatcher";
|
||||
|
||||
private readonly AadJoinWatcher _watcher;
|
||||
private readonly AadJoinWatcherAdapter _adapter;
|
||||
private int _disposed;
|
||||
|
||||
public AadJoinHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock)
|
||||
{
|
||||
_watcher = new AadJoinWatcher(logger);
|
||||
_adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock);
|
||||
}
|
||||
|
||||
public void Start() => _watcher.Start();
|
||||
public void Stop() => _watcher.Stop();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ImeLogHost : ICollectorHost
|
||||
{
|
||||
public string Name => "ImeLogTracker";
|
||||
|
||||
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
|
||||
|
||||
private readonly ImeLogTracker _tracker;
|
||||
private readonly ImeProcessWatcher _processWatcher;
|
||||
private readonly ImeLogTrackerAdapter _adapter;
|
||||
private readonly AgentLogger _logger;
|
||||
private int _disposed;
|
||||
|
||||
public ImeLogHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
string? imeLogPathOverride,
|
||||
string? imeMatchLogPath,
|
||||
List<ImeLogPattern>? imePatterns,
|
||||
string stateDirectory,
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds)
|
||||
{
|
||||
_logger = logger;
|
||||
var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!;
|
||||
var expandedMatchLogPath = string.IsNullOrEmpty(imeMatchLogPath)
|
||||
? null
|
||||
: Environment.ExpandEnvironmentVariables(imeMatchLogPath);
|
||||
var patterns = imePatterns ?? new List<ImeLogPattern>();
|
||||
|
||||
_tracker = new ImeLogTracker(
|
||||
logFolder: logFolder,
|
||||
patterns: patterns,
|
||||
logger: logger,
|
||||
matchLogPath: expandedMatchLogPath,
|
||||
stateDirectory: stateDirectory);
|
||||
|
||||
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds);
|
||||
|
||||
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_processWatcher.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _processWatcher.Dispose(); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogHost: processWatcher dispose failed: {ex.Message}"); }
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogHost: tracker stop failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _processWatcher.Dispose(); } catch { }
|
||||
try { _tracker.Stop(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StallProbeHost : ICollectorHost
|
||||
{
|
||||
public string Name => "StallProbeCollector";
|
||||
|
||||
private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly StallProbeCollector _collector;
|
||||
private readonly StallProbeCollectorAdapter _adapter;
|
||||
private readonly AgentLogger _logger;
|
||||
private Timer? _tickTimer;
|
||||
private readonly DateTime _startedAtUtc;
|
||||
private int _disposed;
|
||||
|
||||
public StallProbeHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
int[]? thresholdsMinutes,
|
||||
int[]? traceIndices,
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds)
|
||||
{
|
||||
_logger = logger;
|
||||
_collector = new StallProbeCollector(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
onEventCollected: onEnrollmentEvent,
|
||||
logger: logger,
|
||||
thresholdsMinutes: thresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 },
|
||||
traceIndices: traceIndices ?? new[] { 2 },
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_startedAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// The collector has no timer of its own — we drive it with a 60-s idle tick.
|
||||
_tickTimer = new Timer(
|
||||
_ => SafeTick(),
|
||||
state: null,
|
||||
dueTime: IdleTickInterval,
|
||||
period: IdleTickInterval);
|
||||
_logger.Info($"StallProbeHost: started (tick every {IdleTickInterval.TotalSeconds}s).");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_tickTimer?.Dispose();
|
||||
_tickTimer = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"StallProbeHost: timer dispose failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void SafeTick()
|
||||
{
|
||||
try
|
||||
{
|
||||
var idleMinutes = (DateTime.UtcNow - _startedAtUtc).TotalMinutes;
|
||||
_collector.CheckAndRunProbes(idleMinutes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("StallProbeHost: tick failed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PerformanceHost : ICollectorHost
|
||||
{
|
||||
public string Name => "PerformanceCollector";
|
||||
|
||||
private readonly PerformanceCollector _collector;
|
||||
private int _disposed;
|
||||
|
||||
public PerformanceHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
AgentLogger logger,
|
||||
int intervalSeconds)
|
||||
{
|
||||
_collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds);
|
||||
}
|
||||
|
||||
public void Start() => _collector.Start();
|
||||
public void Stop() => _collector.Stop();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _collector.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AgentSelfMetricsHost : ICollectorHost
|
||||
{
|
||||
public string Name => "AgentSelfMetricsCollector";
|
||||
|
||||
private readonly AgentSelfMetricsCollector _collector;
|
||||
private int _disposed;
|
||||
|
||||
public AgentSelfMetricsHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
AgentLogger logger,
|
||||
NetworkMetrics networkMetrics,
|
||||
string agentVersion,
|
||||
int intervalSeconds)
|
||||
{
|
||||
_collector = new AgentSelfMetricsCollector(
|
||||
sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds);
|
||||
}
|
||||
|
||||
public void Start() => _collector.Start();
|
||||
public void Stop() => _collector.Stop();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _collector.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
var safeSink = BuildTelemetryEventSink();
|
||||
_collectorHosts = _componentFactory.CreateCollectorHosts(
|
||||
_sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds);
|
||||
_sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds,
|
||||
ingress: _ingress, clock: _clock);
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c.
|
||||
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c + M4.5.b.
|
||||
/// <para>
|
||||
/// Der <see cref="EnrollmentOrchestrator"/> delegiert an diese Factory, weil die konkreten
|
||||
/// Collector-Ctoren heavy Production-Deps (Event-Log-Watcher, WMI, Registry) haben — Tests
|
||||
@@ -35,12 +36,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null
|
||||
/// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>M4.5.b-Extension</b>: <paramref name="ingress"/> und <paramref name="clock"/> werden
|
||||
/// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters
|
||||
/// brauchen einen <see cref="ISignalIngressSink"/> + <see cref="IClock"/>, die erst im
|
||||
/// Orchestrator gebaut werden).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
IReadOnlyList<ICollectorHost> CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds);
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists the current enrollment SessionId across agent restarts. Plan §4.x M4.5.b.
|
||||
/// <para>
|
||||
/// V2 uses a single <c>session.id</c> text file in the agent data directory — simpler than
|
||||
/// the Legacy <c>SessionPersistence</c> (no <c>.seq</c>, no white-glove marker, no session-age
|
||||
/// tracking; those live in the new DecisionState snapshot/journal).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Write semantics are atomic (write-to-temp + <see cref="File.Replace(string,string,string)"/>) so a
|
||||
/// power-cut mid-write cannot leave a zero-byte file that later reads as "no session" and
|
||||
/// trips a fresh SessionId on recovery.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionIdPersistence
|
||||
{
|
||||
private readonly string _sessionFilePath;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
public SessionIdPersistence(string dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory))
|
||||
throw new ArgumentNullException(nameof(dataDirectory));
|
||||
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the persisted SessionId or creates, writes and returns a fresh one.
|
||||
/// Thread-safe — concurrent callers see the same SessionId.
|
||||
/// </summary>
|
||||
public string GetOrCreate(AgentLogger logger = null)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_sessionFilePath))
|
||||
{
|
||||
var existing = File.ReadAllText(_sessionFilePath).Trim();
|
||||
if (Guid.TryParse(existing, out _))
|
||||
{
|
||||
logger?.Debug($"SessionIdPersistence: resumed existing SessionId={existing}.");
|
||||
return existing;
|
||||
}
|
||||
logger?.Warning($"SessionIdPersistence: session.id contained non-GUID content — regenerating.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"SessionIdPersistence: failed to read session.id ({ex.Message}) — regenerating.");
|
||||
}
|
||||
|
||||
var fresh = Guid.NewGuid().ToString();
|
||||
WriteAtomic(fresh, logger);
|
||||
logger?.Info($"SessionIdPersistence: created new SessionId={fresh}.");
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes the persisted SessionId. Next <see cref="GetOrCreate"/> starts a fresh session.</summary>
|
||||
public void Delete(AgentLogger logger = null)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_sessionFilePath)) File.Delete(_sessionFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"SessionIdPersistence: failed to delete session.id: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteAtomic(string sessionId, AgentLogger logger)
|
||||
{
|
||||
var tempPath = _sessionFilePath + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tempPath, sessionId);
|
||||
|
||||
if (File.Exists(_sessionFilePath))
|
||||
{
|
||||
// File.Replace preserves ACLs + is atomic on NTFS.
|
||||
File.Replace(tempPath, _sessionFilePath, destinationBackupFileName: null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, _sessionFilePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Error($"SessionIdPersistence: atomic write failed ({ex.Message}) — falling back to direct write.", ex);
|
||||
File.WriteAllText(_sessionFilePath, sessionId);
|
||||
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the Autopilot device's AAD TenantId from the MDM enrollment registry.
|
||||
/// Plan §4.x M4.5.b.
|
||||
/// <para>
|
||||
/// The MDM enrollment writes one sub-key per enrollment under
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\Enrollments</c>. The entry with <c>EnrollmentType==6</c>
|
||||
/// (MDM Intune enrollment) carries <c>AADTenantID</c> — the only value that maps the
|
||||
/// device back to its Entra ID tenant without a backend round-trip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TenantIdResolver
|
||||
{
|
||||
private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
|
||||
private const int IntuneMdmEnrollmentType = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Walks the enrollments subtree and returns the first <c>AADTenantID</c> tagged with
|
||||
/// the Intune MDM enrollment type. Returns <c>null</c> when the device is not MDM-enrolled
|
||||
/// or the registry key is inaccessible. Never throws.
|
||||
/// </summary>
|
||||
public static string ResolveFromEnrollmentRegistry(AgentLogger logger = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath))
|
||||
{
|
||||
if (enrollmentsKey == null)
|
||||
{
|
||||
logger?.Warning($"TenantIdResolver: {EnrollmentsKeyPath} not found — device likely not MDM-enrolled.");
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
|
||||
{
|
||||
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
|
||||
{
|
||||
if (enrollmentKey == null) continue;
|
||||
|
||||
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
|
||||
if (enrollmentType == null) continue;
|
||||
|
||||
int typeValue;
|
||||
try { typeValue = Convert.ToInt32(enrollmentType); }
|
||||
catch { continue; }
|
||||
|
||||
if (typeValue != IntuneMdmEnrollmentType) continue;
|
||||
|
||||
var tenantId = enrollmentKey.GetValue("AADTenantID");
|
||||
var resolved = tenantId?.ToString();
|
||||
if (!string.IsNullOrEmpty(resolved))
|
||||
{
|
||||
logger?.Debug($"TenantIdResolver: resolved AADTenantID={resolved} from enrollment {enrollmentGuid}.");
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Warning("TenantIdResolver: no Intune MDM enrollment (EnrollmentType=6) with AADTenantID found.");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Error("TenantIdResolver: registry probe failed.", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,49 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
/// <summary>
|
||||
/// V2-Agent entry point — M2 skeleton.
|
||||
/// V2-Agent entry point. Plan §4.x M4.5.b.
|
||||
/// <para>
|
||||
/// Plan §4 M2 release gate: "V2-Agent-Exe startet + terminiert sauber (ohne echtes Enrollment).
|
||||
/// Harness durchläuft leeren Signal-Stream deterministisch."
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Real startup wiring (orchestrator, collectors, signal ingress, transport) lands in M4.
|
||||
/// Start sequence:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Parse CLI args (<c>--help</c>, <c>--version</c>, <c>--console</c>, <c>--new-session</c>,
|
||||
/// <c>--api-url</c>, <c>--bootstrap-token</c>, <c>--await-enrollment</c>, <c>--ime-log-path</c>,
|
||||
/// <c>--ime-match-log</c>)</item>
|
||||
/// <item>Initialise <see cref="AgentLogger"/> on <c>%ProgramData%\AutopilotMonitor\Logs</c></item>
|
||||
/// <item>Resolve TenantId via <see cref="TenantIdResolver"/></item>
|
||||
/// <item>Load/create SessionId via <see cref="SessionIdPersistence"/></item>
|
||||
/// <item>(Optional) Wait for MDM certificate in await-enrollment mode</item>
|
||||
/// <item>Build mTLS-<see cref="HttpClient"/> via <see cref="MtlsHttpClientFactory"/></item>
|
||||
/// <item>Fetch remote config via <see cref="BackendApiClient"/> + <see cref="RemoteConfigService"/></item>
|
||||
/// <item>Wire <see cref="BackendTelemetryUploader"/> for telemetry batch uploads</item>
|
||||
/// <item>Construct <see cref="DefaultComponentFactory"/> + <see cref="EnrollmentOrchestrator"/></item>
|
||||
/// <item><c>orchestrator.Start()</c> and wait for <c>Ctrl+C</c> / <see cref="AppDomain.ProcessExit"/></item>
|
||||
/// <item><c>orchestrator.Stop()</c>, return 0</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private const string DefaultStateDirectory = @"%ProgramData%\AutopilotMonitor";
|
||||
private const string DefaultLogDirectory = @"%ProgramData%\AutopilotMonitor\Logs";
|
||||
private const string DefaultV2StateSubdirectory = "V2";
|
||||
private const string DefaultTransportSubdirectory = "V2Transport";
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?"))
|
||||
@@ -30,29 +58,316 @@ namespace AutopilotMonitor.Agent.V2
|
||||
return 0;
|
||||
}
|
||||
|
||||
// M2 skeleton: exits cleanly. No enrollment logic, no collectors, no IO.
|
||||
// Additional modes (--install, --run-gather-rules, --run-ime-matching, normal runtime)
|
||||
// will be added as the corresponding V2 subsystems come online in M3–M6.
|
||||
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 skeleton — M2. No operational mode wired yet.");
|
||||
Console.Out.WriteLine("Run with --help for available flags.");
|
||||
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
|
||||
|
||||
var dataDirectory = Environment.ExpandEnvironmentVariables(DefaultStateDirectory);
|
||||
var logDirectory = Environment.ExpandEnvironmentVariables(DefaultLogDirectory);
|
||||
|
||||
var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode };
|
||||
logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()}).");
|
||||
|
||||
try
|
||||
{
|
||||
return RunAgent(args, logger, dataDirectory, consoleMode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("V2 agent startup failed.", ex);
|
||||
if (consoleMode) Console.Error.WriteLine($"FATAL: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Orchestration
|
||||
|
||||
private static int RunAgent(string[] args, AgentLogger logger, string dataDirectory, bool consoleMode)
|
||||
{
|
||||
if (!Directory.Exists(dataDirectory)) Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
logger.Error("V2 agent cannot start: TenantId could not be resolved from the enrollment registry. Device is not MDM-enrolled.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (args.Contains("--new-session"))
|
||||
{
|
||||
new SessionIdPersistence(dataDirectory).Delete(logger);
|
||||
logger.Info("--new-session: cleared persisted SessionId.");
|
||||
}
|
||||
|
||||
var sessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
|
||||
|
||||
var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId);
|
||||
|
||||
if (agentConfig.AwaitEnrollment)
|
||||
{
|
||||
logger.Info($"--await-enrollment: polling for MDM certificate (timeout: {agentConfig.AwaitEnrollmentTimeoutMinutes}min).");
|
||||
using (var cts = new CancellationTokenSource())
|
||||
{
|
||||
var cert = EnrollmentAwaiter
|
||||
.WaitForMdmCertificateAsync(
|
||||
thumbprint: null,
|
||||
timeoutMinutes: agentConfig.AwaitEnrollmentTimeoutMinutes,
|
||||
logger: logger,
|
||||
cancellationToken: cts.Token)
|
||||
.GetAwaiter().GetResult();
|
||||
if (cert == null)
|
||||
{
|
||||
logger.Error("Await-enrollment: timed out waiting for MDM certificate — exiting.");
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var backendApiClient = new BackendApiClient(
|
||||
baseUrl: agentConfig.ApiBaseUrl,
|
||||
configuration: agentConfig,
|
||||
logger: logger,
|
||||
agentVersion: GetAgentVersion());
|
||||
|
||||
var remoteConfigService = new RemoteConfigService(backendApiClient, tenantId, logger);
|
||||
var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
|
||||
|
||||
logger.SetLogLevel(agentConfig.LogLevel);
|
||||
|
||||
HttpClient mtlsHttpClient;
|
||||
BackendTelemetryUploader uploader;
|
||||
try
|
||||
{
|
||||
mtlsHttpClient = MtlsHttpClientFactory.Create(
|
||||
resolver: new DefaultCertificateResolver(),
|
||||
logger: logger);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex);
|
||||
return 4;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hardware = HardwareInfo.GetHardwareInfo(logger);
|
||||
uploader = new BackendTelemetryUploader(
|
||||
httpClient: mtlsHttpClient,
|
||||
baseUrl: agentConfig.ApiBaseUrl,
|
||||
tenantId: tenantId,
|
||||
manufacturer: hardware.Manufacturer,
|
||||
model: hardware.Model,
|
||||
serialNumber: hardware.SerialNumber,
|
||||
bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
|
||||
agentVersion: GetAgentVersion());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("BackendTelemetryUploader construction failed.", ex);
|
||||
return 5;
|
||||
}
|
||||
|
||||
var stateSubdir = Path.Combine(dataDirectory, DefaultV2StateSubdirectory);
|
||||
var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory);
|
||||
|
||||
var classifiers = new IClassifier[]
|
||||
{
|
||||
new WhiteGloveSealingClassifier(),
|
||||
new WhiteGlovePart2CompletionClassifier(),
|
||||
};
|
||||
|
||||
var componentFactory = new DefaultComponentFactory(
|
||||
agentConfig: agentConfig,
|
||||
remoteConfig: remoteConfig,
|
||||
networkMetrics: backendApiClient.NetworkMetrics,
|
||||
agentVersion: GetAgentVersion(),
|
||||
stateDirectory: stateSubdir);
|
||||
|
||||
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
|
||||
?? Array.Empty<string>();
|
||||
|
||||
using (var orchestrator = new EnrollmentOrchestrator(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
stateDirectory: stateSubdir,
|
||||
transportDirectory: transportDir,
|
||||
clock: SystemClock.Instance,
|
||||
logger: logger,
|
||||
uploader: uploader,
|
||||
classifiers: classifiers,
|
||||
componentFactory: componentFactory,
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds))
|
||||
{
|
||||
using (var shutdown = new ManualResetEventSlim(false))
|
||||
{
|
||||
ConsoleCancelEventHandler cancelHandler = (s, e) =>
|
||||
{
|
||||
// Prevent the process from terminating immediately — let the orchestrator drain first.
|
||||
e.Cancel = true;
|
||||
logger.Info("Ctrl+C received — initiating graceful shutdown.");
|
||||
shutdown.Set();
|
||||
};
|
||||
EventHandler processExitHandler = (s, e) =>
|
||||
{
|
||||
logger.Info("ProcessExit — initiating graceful shutdown.");
|
||||
shutdown.Set();
|
||||
};
|
||||
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
|
||||
|
||||
try
|
||||
{
|
||||
orchestrator.Start();
|
||||
logger.Info($"V2 agent runtime ready (session={sessionId}, tenant={tenantId}).");
|
||||
if (consoleMode)
|
||||
{
|
||||
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop.");
|
||||
}
|
||||
|
||||
shutdown.Wait();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
|
||||
|
||||
try { orchestrator.Stop(); }
|
||||
catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); }
|
||||
|
||||
try { mtlsHttpClient.Dispose(); } catch { }
|
||||
try { backendApiClient.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("AutopilotMonitor.Agent.V2 stopped cleanly.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Configuration
|
||||
|
||||
private static AgentConfiguration BuildAgentConfiguration(string[] args, string tenantId, string sessionId)
|
||||
{
|
||||
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
|
||||
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
|
||||
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
|
||||
var bootstrapToken = GetArgValue(args, "--bootstrap-token");
|
||||
var awaitEnrollment = args.Contains("--await-enrollment");
|
||||
var rebootOnComplete = args.Contains("--reboot-on-complete");
|
||||
var disableGeoLocation = args.Contains("--disable-geolocation");
|
||||
var keepLogFile = args.Contains("--keep-logfile");
|
||||
|
||||
var awaitTimeoutRaw = GetArgValue(args, "--await-enrollment-timeout");
|
||||
var awaitTimeoutMinutes = 480;
|
||||
if (!string.IsNullOrEmpty(awaitTimeoutRaw) && int.TryParse(awaitTimeoutRaw, out var parsedTimeout))
|
||||
awaitTimeoutMinutes = parsedTimeout;
|
||||
|
||||
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
|
||||
|
||||
return new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = apiBaseUrl,
|
||||
SessionId = sessionId,
|
||||
TenantId = tenantId,
|
||||
SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
|
||||
LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds,
|
||||
MaxBatchSize = Constants.MaxBatchSize,
|
||||
LogLevel = AgentLogLevel.Info,
|
||||
UseClientCertAuth = !useBootstrapTokenAuth,
|
||||
BootstrapToken = bootstrapToken,
|
||||
UseBootstrapTokenAuth = useBootstrapTokenAuth,
|
||||
AwaitEnrollment = awaitEnrollment,
|
||||
AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes,
|
||||
RebootOnComplete = rebootOnComplete,
|
||||
EnableGeoLocation = !disableGeoLocation,
|
||||
ImeLogPathOverride = imeLogPathOverride,
|
||||
ImeMatchLogPath = imeMatchLogPath,
|
||||
KeepLogFile = keepLogFile,
|
||||
CommandLineArgs = FormatArgsForLog(args),
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetArgValue(string[] args, params string[] names)
|
||||
{
|
||||
if (args == null || args.Length < 2 || names == null) return null;
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatArgsForLog(string[] args)
|
||||
{
|
||||
if (args == null || args.Length == 0) return string.Empty;
|
||||
|
||||
var parts = new System.Collections.Generic.List<string>(args.Length);
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var a = args[i];
|
||||
if (string.Equals(a, "--bootstrap-token", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
parts.Add(a);
|
||||
parts.Add("[redacted]");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
parts.Add(a);
|
||||
}
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Info
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent (kernel-only refactor).");
|
||||
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent.");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Usage:");
|
||||
Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [--help | --version]");
|
||||
Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [options]");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Operational modes (install, gather-rules, ime-matching, runtime) are added in M3–M6.");
|
||||
Console.Out.WriteLine("Options:");
|
||||
Console.Out.WriteLine(" --help, -h, -? Show this message and exit");
|
||||
Console.Out.WriteLine(" --version Print version and exit");
|
||||
Console.Out.WriteLine(" --console Mirror log output to stdout");
|
||||
Console.Out.WriteLine(" --new-session Discard any persisted SessionId and create a fresh one");
|
||||
Console.Out.WriteLine(" --api-url <url> Override backend base URL (alias: --backend-api)");
|
||||
Console.Out.WriteLine(" --bootstrap-token <tok> Use pre-MDM bootstrap token auth instead of client cert");
|
||||
Console.Out.WriteLine(" --await-enrollment Wait for MDM certificate before starting");
|
||||
Console.Out.WriteLine(" --await-enrollment-timeout <min> Await-enrollment timeout (default: 480min)");
|
||||
Console.Out.WriteLine(" --ime-log-path <dir> Override IME log folder (testing/replay)");
|
||||
Console.Out.WriteLine(" --ime-match-log <path> Write IME pattern matches to this file");
|
||||
Console.Out.WriteLine(" --reboot-on-complete Reboot device after successful enrollment");
|
||||
Console.Out.WriteLine(" --disable-geolocation Disable external IP geo-location lookup");
|
||||
Console.Out.WriteLine(" --keep-logfile Preserve the agent log file on self-destruct");
|
||||
}
|
||||
|
||||
private static void PrintVersion()
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
var version = asm.GetName().Version?.ToString() ?? "0.0.0.0";
|
||||
Console.Out.WriteLine(version);
|
||||
Console.Out.WriteLine(GetAgentVersion());
|
||||
}
|
||||
|
||||
private static string GetAgentVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
if (!string.IsNullOrWhiteSpace(info)) return info;
|
||||
|
||||
var file = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
|
||||
if (!string.IsNullOrWhiteSpace(file)) return file;
|
||||
|
||||
return asm.GetName().Version?.ToString() ?? "0.0.0.0";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "0.0.0.0";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
15
|
||||
24
|
||||
|
||||
@@ -1 +1 @@
|
||||
1057
|
||||
1064
|
||||
|
||||
Reference in New Issue
Block a user