From 7e199019cedfe4059ebf41f36efc7a1763ca4827 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 21 Apr 2026 00:50:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent-v2):=20M4.6.=CE=B3=20diagnostic=20pr?= =?UTF-8?q?obes,=20reporters,=20DO=20collector=20wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the third batch of peripheral-parity gaps — Legacy's fire-and-forget startup probes, the two cert-less + authenticated error-reporting channels, and Delivery-Optimization telemetry all run in V2. StartupEnvironmentProbes (V2.Core/Runtime/) - New helper that replicates the Legacy MonitoringService startup diagnostic trio: device_location / timezone_auto_set / ntp_time_check. - Sequential RunAsync(config, logger, emitter): geo lookup → (optional) tzutil auto-set → NTP offset check. Each step is best-effort and feeds through the orchestrator's TelemetryEventEmitter. - Geo probe respects EnableGeoLocation; timezone probe respects EnableTimezoneAutoSet AND requires an IANA tz from geo. NTP probe always runs (diagnostic-only; Warning on offset >60s or failure). - Program.cs wires this via Task.Run right after orchestrator.Start so a slow network never delays the critical path. EmergencyReporter + DistressReporter (live in Program.cs) - Previously instantiated nowhere — now built once at startup with the shared hardware info. RemoteConfigService ctor takes both so config-fetch failures flow to the right channel (auth → distress, network → emergency). - Initial AuthCertificateMissing distress fires when UseClientCertAuth=true but BackendApiClient.ClientCertificate is null — surfaces pre-MDM- enrollment dead-ends via the cert-less channel (Legacy parity). - BackendTelemetryUploader uses the same hardware info (dedup'd the second HardwareInfo.GetHardwareInfo call that was duplicated since M4.5.b). DeliveryOptimizationHost (DefaultComponentFactory nested) - New ICollectorHost wrapping DeliveryOptimizationCollector with: • getPackageStates → ImeLogHost.PackageStates (existing accessor) • onDoTelemetryReceived → ImeLogHost.Tracker.OnDoTelemetryReceived pass- through (new internal accessor; keeps encapsulation) • OnAppStateChanged chained into ImeLogTracker so WakeUp() fires when any app enters Downloading or Installing — before any fire we preserve the existing handler (the adapter's signal-ingest path must keep running unchanged). - Gated by CollectorConfiguration.EnableDeliveryOptimizationCollector. Tests (+8 in V2.Core.Tests → 353 → 361) - StartupEnvironmentProbesTests: event-shape for all three probes + geo success/failure/null attempt, timezone success/failure severity, NTP small/large offset + failure-with-detail. All 4 suites green: 88 DecisionCore + 361 V2.Core + 507 Agent.Core + 557 Functions = 1513 tests green. Note: the "RegistryWatcher standalone admin-action-detector" from the original M4.6 gap list was a Subagent fabrication — verified no such usage exists in either Legacy or V2 (the class is only consumed transitively inside AadJoinWatcher + ProvisioningStatusTracker, both already wired via DefaultComponentFactory since M4.5.b). Removed from the M4.6.γ scope. Remaining: M4.6.δ (--run-gather-rules + --run-ime-matching modes, AnalyzerManager + 3 Analyzers). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Runtime/StartupEnvironmentProbesTests.cs | 182 ++++++++++++++ .../Orchestration/DefaultComponentFactory.cs | 99 +++++++- .../Runtime/StartupEnvironmentProbes.cs | 229 ++++++++++++++++++ .../AutopilotMonitor.Agent.V2/Program.cs | 56 ++++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 6 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs new file mode 100644 index 00000000..a6722f01 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs @@ -0,0 +1,182 @@ +#nullable enable +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime +{ + /// + /// M4.6.γ — event-shape tests for the Legacy-parity startup probes. We cover the pure event + /// builders directly; the end-to-end probe runner itself needs live HTTP/UDP/tzutil (no value + /// in unit testing — excercised via the V2-agent test-VM smoke run deferred to Follow-Up). + /// + public sealed class StartupEnvironmentProbesTests + { + private static AgentConfiguration Config(bool geoEnabled = true, bool tzAutoSet = false, string? ntpServer = null) => + new AgentConfiguration + { + SessionId = "S1", + TenantId = "T1", + EnableGeoLocation = geoEnabled, + EnableTimezoneAutoSet = tzAutoSet, + NtpServer = ntpServer, + }; + + // ================================================================= Geo success + + [Fact] + public void BuildGeoEvent_returns_info_device_location_event_with_all_fields() + { + var cfg = Config(); + var loc = new GeoLocationResult + { + Country = "DE", + Region = "BW", + City = "Stuttgart", + Loc = "48.7,9.1", + Timezone = "Europe/Berlin", + Source = "ipinfo.io", + }; + + var evt = StartupEnvironmentProbes.BuildGeoEvent(cfg, loc); + + Assert.Equal("device_location", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Equal("Network", evt.Source); + Assert.Equal("S1", evt.SessionId); + Assert.Equal("T1", evt.TenantId); + Assert.True(evt.ImmediateUpload); + Assert.Contains("Stuttgart", evt.Message); + Assert.Equal("DE", evt.Data["country"]); + Assert.Equal("ipinfo.io", evt.Data["source"]); + } + + // ================================================================= Geo failure + + [Fact] + public void BuildGeoFailureEvent_returns_warning_agent_trace_with_provider_errors() + { + var cfg = Config(); + var attempt = new GeoLocationAttemptResult + { + PrimaryError = "connection refused", + PrimaryRetryError = "DNS failure", + FallbackError = "timeout", + }; + + var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt); + + Assert.Equal("agent_trace", evt.EventType); + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Equal("geo_location_failed", evt.Data["decision"]); + Assert.Equal("connection refused", evt.Data["primaryError"]); + Assert.Equal("DNS failure", evt.Data["primaryRetryError"]); + Assert.Equal("timeout", evt.Data["fallbackError"]); + } + + [Fact] + public void BuildGeoFailureEvent_handles_null_attempt_gracefully() + { + var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(Config(), attempt: null); + + Assert.Equal("agent_trace", evt.EventType); + Assert.Equal("unknown", evt.Data["primaryError"]); + Assert.Equal("unknown", evt.Data["primaryRetryError"]); + Assert.Equal("unknown", evt.Data["fallbackError"]); + } + + // ================================================================= Timezone + + [Fact] + public void BuildTimezoneEvent_info_on_success() + { + var tz = new TimezoneSetResult + { + Success = true, + IanaTimezone = "Europe/Berlin", + WindowsTimezoneId = "W. Europe Standard Time", + PreviousTimezone = "UTC", + }; + + var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz); + + Assert.Equal("timezone_auto_set", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Contains("W. Europe Standard Time", evt.Message); + Assert.Equal(true, evt.Data["success"]); + } + + [Fact] + public void BuildTimezoneEvent_warning_on_failure() + { + var tz = new TimezoneSetResult + { + Success = false, + IanaTimezone = "Europe/Berlin", + Error = "tzutil failed", + }; + + var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Contains("tzutil failed", evt.Message); + Assert.Equal("tzutil failed", evt.Data["error"]); + } + + // ================================================================= NTP + + [Fact] + public void BuildNtpEvent_info_for_small_offset() + { + var r = new NtpCheckResult + { + Success = true, + OffsetSeconds = 2.3, + NtpTime = new System.DateTime(2026, 4, 21, 10, 0, 0, System.DateTimeKind.Utc), + LocalTime = new System.DateTime(2026, 4, 21, 10, 0, 2, System.DateTimeKind.Utc), + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal("ntp_time_check", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Equal("time.windows.com", evt.Data["ntpServer"]); + Assert.Equal(2.3, evt.Data["offsetSeconds"]); + } + + [Fact] + public void BuildNtpEvent_warning_for_large_offset() + { + var r = new NtpCheckResult + { + Success = true, + OffsetSeconds = 120.0, + NtpTime = System.DateTime.UtcNow, + LocalTime = System.DateTime.UtcNow, + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + } + + [Fact] + public void BuildNtpEvent_warning_on_failure_with_error_detail() + { + var r = new NtpCheckResult + { + Success = false, + Error = "DNS resolution failed", + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Contains("DNS resolution failed", evt.Message); + Assert.Equal("DNS resolution failed", evt.Data["error"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index d490f41e..2d6fd300 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -166,6 +166,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds)); } + // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the + // IME log tracker reports an app entering Downloading/Installing (see AppStateChanged + // chain below). Needs the IME tracker's PackageStates + OnDoTelemetryReceived hook. + if (collectors.EnableDeliveryOptimizationCollector && _imeLogHost != null) + { + var doHost = new DeliveryOptimizationHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + intervalSeconds: collectors.DeliveryOptimizationIntervalSeconds, + imeHost: _imeLogHost); + hosts.Add(doHost); + } + return hosts; } @@ -287,10 +302,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// Exposes the tracker's live package-state list for peripheral consumers - /// (M4.6.β FinalStatusBuilder). + /// (M4.6.β FinalStatusBuilder, M4.6.γ DeliveryOptimizationHost). /// public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates; + /// + /// Reference to the wrapped IME tracker for co-collector wiring. Used by + /// DeliveryOptimizationHost to set OnDoTelemetryReceived and to chain + /// OnAppStateChanged for dormant/wake-up transitions. + /// + internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker; + public ImeLogHost( string sessionId, string tenantId, @@ -457,6 +479,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } } + private sealed class DeliveryOptimizationHost : ICollectorHost + { + public string Name => "DeliveryOptimizationCollector"; + + private readonly DeliveryOptimizationCollector _collector; + private readonly ImeLogHost _imeHost; + private readonly AgentLogger _logger; + private Action? _prevStateChanged; + private int _disposed; + + public DeliveryOptimizationHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + int intervalSeconds, + ImeLogHost imeHost) + { + _imeHost = imeHost; + _logger = logger; + + _collector = new DeliveryOptimizationCollector( + sessionId: sessionId, + tenantId: tenantId, + emitEvent: onEnrollmentEvent, + logger: logger, + intervalSeconds: intervalSeconds, + getPackageStates: () => imeHost.PackageStates, + onDoTelemetryReceived: pkg => + { + try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); } + catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); } + }, + logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory)); + } + + public void Start() + { + // Chain ourselves into the tracker's OnAppStateChanged so the DO collector wakes + // up on the first Downloading/Installing transition (Legacy parity). We preserve + // any existing handler — the IME adapter's own listener must keep running. + _prevStateChanged = _imeHost.Tracker.OnAppStateChanged; + _imeHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) => + { + try { _prevStateChanged?.Invoke(pkg, oldState, newState); } + catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); } + + if (newState >= Monitoring.Enrollment.Ime.AppInstallationState.Downloading && + newState <= Monitoring.Enrollment.Ime.AppInstallationState.Installing) + { + try { _collector.WakeUp(); } + catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); } + } + }; + + _collector.Start(); + _logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing)."); + } + + public void Stop() + { + // Restore the previous handler only if we're still the current one — otherwise + // someone else replaced us and owns the slot now. + if (object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _imeHost.Tracker.OnAppStateChanged)) + _imeHost.Tracker.OnAppStateChanged = _prevStateChanged; + _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"; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs new file mode 100644 index 00000000..5814367f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs @@ -0,0 +1,229 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Runtime +{ + /// + /// Fire-and-forget startup probes ported from the Legacy MonitoringService. + /// Plan §4.x M4.6.γ. + /// + /// Emits three diagnostic events when the orchestrator is live: + /// + /// + /// device_location (or agent_trace on failure) — IP-based geo via + /// . Skipped when EnableGeoLocation=false. + /// timezone_auto_set — only when EnableTimezoneAutoSet=true AND the + /// geo lookup returned an IANA timezone. Uses (tzutil). + /// ntp_time_check — NTP offset from NtpServer (default time.windows.com). + /// Always emitted — offset >60s is Warning, smaller is Info, failure is Warning. + /// + /// + /// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event, + /// never thrown. Designed to run on a background task so they never block the critical path. + /// + /// + public static class StartupEnvironmentProbes + { + /// + /// Runs all three probes sequentially and emits the resulting events via + /// . Returns a that completes when all + /// probes have finished (typical runtime <10s; longer on slow/blocked networks). + /// + public static async Task RunAsync( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + if (emitter == null) throw new ArgumentNullException(nameof(emitter)); + + if (configuration.EnableGeoLocation) + await RunGeoAndTimezone(configuration, logger, emitter).ConfigureAwait(false); + else + logger.Debug("StartupEnvironmentProbes: EnableGeoLocation=false — skipping geo + timezone probes."); + + await RunNtpCheck(configuration, logger, emitter).ConfigureAwait(false); + } + + private static async Task RunGeoAndTimezone( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + GeoLocationAttemptResult? attempt = null; + try + { + attempt = await GeoLocationService.GetLocationAsync(logger).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: geo-location lookup threw: {ex.Message}"); + } + + if (attempt?.Location != null) + { + SafeEmit(emitter, logger, BuildGeoEvent(configuration, attempt.Location)); + } + else + { + SafeEmit(emitter, logger, BuildGeoFailureEvent(configuration, attempt)); + } + + if (configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt?.Location?.Timezone)) + { + try + { + var tzResult = TimezoneService.TrySetTimezone(attempt!.Location!.Timezone, logger); + SafeEmit(emitter, logger, BuildTimezoneEvent(configuration, tzResult)); + } + catch (Exception tzEx) + { + logger.Warning($"StartupEnvironmentProbes: timezone auto-set threw: {tzEx.Message}"); + } + } + } + + private static async Task RunNtpCheck( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + try + { + var ntpServer = string.IsNullOrEmpty(configuration.NtpServer) ? "time.windows.com" : configuration.NtpServer; + + // NtpTimeCheckService.CheckTime is synchronous — wrap on a background thread so the + // probe runner stays async-friendly (avoids blocking the continuation thread when + // Program.cs awaits RunAsync with ConfigureAwait(false)). + var result = await Task.Run(() => NtpTimeCheckService.CheckTime(ntpServer, logger)) + .ConfigureAwait(false); + + SafeEmit(emitter, logger, BuildNtpEvent(configuration, ntpServer, result)); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: NTP check threw: {ex.Message}"); + } + } + + // --------------------------------------------------------------- Event builders (internal for tests) + + internal static EnrollmentEvent BuildGeoEvent(AgentConfiguration configuration, GeoLocationResult location) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "device_location", + Severity = EventSeverity.Info, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})", + Data = location.ToDictionary(), + ImmediateUpload = true, + }; + + internal static EnrollmentEvent BuildGeoFailureEvent(AgentConfiguration configuration, GeoLocationAttemptResult? attempt) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "agent_trace", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = "Geo-location lookup failed: all providers unreachable", + Data = new Dictionary + { + { "decision", "geo_location_failed" }, + { "reason", "All geo-location providers failed after retry" }, + { "primaryError", attempt?.PrimaryError ?? "unknown" }, + { "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" }, + { "fallbackError", attempt?.FallbackError ?? "unknown" }, + { "primaryProvider", "ipinfo.io" }, + { "fallbackProvider", "ifconfig.co" }, + }, + }; + + internal static EnrollmentEvent BuildTimezoneEvent(AgentConfiguration configuration, TimezoneSetResult tz) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "timezone_auto_set", + Severity = tz.Success ? EventSeverity.Info : EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = tz.Success + ? $"Timezone set to {tz.WindowsTimezoneId} (from {tz.IanaTimezone})" + : $"Timezone auto-set failed: {tz.Error}", + Data = new Dictionary + { + { "ianaTimezone", tz.IanaTimezone ?? "" }, + { "windowsTimezoneId", tz.WindowsTimezoneId ?? "unknown" }, + { "previousTimezone", tz.PreviousTimezone ?? "unknown" }, + { "success", tz.Success }, + { "error", tz.Error ?? "" }, + }, + }; + + internal static EnrollmentEvent BuildNtpEvent(AgentConfiguration configuration, string ntpServer, NtpCheckResult result) + { + if (result.Success) + { + var severity = Math.Abs(result.OffsetSeconds) > 60 ? EventSeverity.Warning : EventSeverity.Info; + return new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "ntp_time_check", + Severity = severity, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "offsetSeconds", result.OffsetSeconds }, + { "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" }, + { "localTimeUtc", result.LocalTime?.ToString("o") ?? "" }, + }, + }; + } + + return new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "ntp_time_check", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"NTP time check failed: {result.Error}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "error", result.Error ?? "unknown" }, + }, + }; + } + + private static void SafeEmit(TelemetryEventEmitter emitter, AgentLogger logger, EnrollmentEvent evt) + { + try { emitter.Emit(evt); } + catch (Exception ex) { logger.Warning($"StartupEnvironmentProbes: emit failed for '{evt.EventType}': {ex.Message}"); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 7377ee5e..ec35faab 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Runtime; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; @@ -259,7 +260,37 @@ namespace AutopilotMonitor.Agent.V2 logger: logger, agentVersion: GetAgentVersion()); - var remoteConfigService = new RemoteConfigService(backendApiClient, agentConfig.TenantId, logger); + // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so + // Config-fetch failures (auth vs network) flow to the correct channel. Also fires + // an initial AuthCertificateMissing distress when the MDM cert was expected but + // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend + // via the cert-less distress channel). + var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger); + var distressReporter = new DistressReporter( + baseUrl: agentConfig.ApiBaseUrl, + tenantId: agentConfig.TenantId, + manufacturer: hardwareForReporters.Manufacturer, + model: hardwareForReporters.Model, + serialNumber: hardwareForReporters.SerialNumber, + agentVersion: GetAgentVersion(), + logger: logger); + + var emergencyReporter = new EmergencyReporter( + apiClient: backendApiClient, + sessionId: agentConfig.SessionId, + tenantId: agentConfig.TenantId, + agentVersion: GetAgentVersion(), + logger: logger); + + if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null) + { + _ = distressReporter.TrySendAsync( + DistressErrorType.AuthCertificateMissing, + "MDM certificate not found in LocalMachine or CurrentUser store"); + } + + var remoteConfigService = new RemoteConfigService( + backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter); var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult(); logger.SetLogLevel(agentConfig.LogLevel); @@ -299,14 +330,13 @@ namespace AutopilotMonitor.Agent.V2 try { - var hardware = HardwareInfo.GetHardwareInfo(logger); uploader = new BackendTelemetryUploader( httpClient: mtlsHttpClient, baseUrl: agentConfig.ApiBaseUrl, tenantId: agentConfig.TenantId, - manufacturer: hardware.Manufacturer, - model: hardware.Model, - serialNumber: hardware.SerialNumber, + manufacturer: hardwareForReporters.Manufacturer, + model: hardwareForReporters.Model, + serialNumber: hardwareForReporters.SerialNumber, bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null, agentVersion: GetAgentVersion()); } @@ -433,6 +463,22 @@ namespace AutopilotMonitor.Agent.V2 // VersionCheckEventBuilder.TryBuild is a no-op when no markers are present. EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger); + // M4.6.γ — fire-and-forget startup probes (geo / timezone / NTP). Runs on + // the ThreadPool so a slow network never delays the critical path. + _ = Task.Run(async () => + { + try + { + await StartupEnvironmentProbes + .RunAsync(agentConfig, logger, orchestrator.EventEmitter) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: outer exception: {ex.Message}"); + } + }); + logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId})."); if (consoleMode) Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop."); diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index f5c89552..8f92bfdd 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -32 +35 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index b9650fd8..48c6edb2 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1068 +1069