added self-deploying mode detection and event tracking for the enrollment finalization process.

This commit is contained in:
Oliver Kieselbach
2026-03-17 19:08:48 +01:00
parent 182357cc91
commit ddb8daceee
10 changed files with 156 additions and 11 deletions
@@ -131,10 +131,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
/// not the actual live enrollment state.
/// </summary>
private (string enrollmentType, bool isHybridJoin) CollectAutopilotProfile()
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
{
var detectedType = "v1";
var isHybridJoin = false;
int? detectedAutopilotMode = null;
try
{
@@ -185,6 +186,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
: null;
if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
{
detectedAutopilotMode = autopilotMode;
string modeLabel;
switch (autopilotMode)
{
@@ -269,7 +271,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
}
return (detectedType, isHybridJoin);
return (detectedType, isHybridJoin, detectedAutopilotMode);
}
private void CollectSecureBootStatus()
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Runs all device info collectors at agent startup.
/// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry.
/// </summary>
public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectAll()
public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode) CollectAll()
{
_logger.Info("EnrollmentTracker: collecting device info (at start)");
@@ -59,7 +59,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
CollectAadJoinStatus();
CollectActiveNetworkInterfaceInfo();
return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage);
return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage, profileResult.autopilotMode);
}
/// <summary>
@@ -55,6 +55,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private HashSet<string> _provisioningCategoriesResolved;
// Track subcategory states per category — detect meaningful state transitions
private Dictionary<string, Dictionary<string, string>> _lastSubcategoryStates;
// Fire-once guard for DeviceSetupProvisioningComplete event
private bool _deviceSetupProvisioningCompleteFired;
private void StartProvisioningStatusWatcher()
{
@@ -64,6 +66,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_provisioningFailureFired = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_provisioningCategoriesResolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_lastSubcategoryStates = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
_deviceSetupProvisioningCompleteFired = false;
// Try to start immediately; if key doesn't exist yet, retry every 2s
if (!TryStartProvisioningWatcher())
@@ -228,6 +231,25 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex);
}
}
// Fire DeviceSetupProvisioningComplete when DeviceSetup category resolves with success.
// Used as completion signal for Self-Deploying mode where Shell-Core ESP exit
// and desktop arrival may never arrive.
if (!_deviceSetupProvisioningCompleteFired
&& _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded)
&& dsSucceeded == true)
{
_deviceSetupProvisioningCompleteFired = true;
_logger.Info("Provisioning status: DeviceSetup succeeded — firing DeviceSetupProvisioningComplete");
try
{
DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("DeviceSetupProvisioningComplete handler failed", ex);
}
}
}
}
}
@@ -77,6 +77,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// </summary>
public event EventHandler<string> EspFailureDetected;
/// <summary>
/// Fired when DeviceSetup provisioning status shows categorySucceeded=true.
/// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit
/// and desktop arrival signals may never arrive.
/// </summary>
public event EventHandler DeviceSetupProvisioningComplete;
/// <summary>
/// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured).
/// Values: "completed", "timeout", "not_configured", "wizard_not_started", null (not yet resolved).
@@ -111,12 +111,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
public bool? SkipUserStatusPage { get; set; }
public bool? SkipDeviceStatusPage { get; set; }
// Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
public int? AutopilotMode { get; set; }
// Signal timestamps for audit trail
public DateTime? EspFirstSeenUtc { get; set; }
public DateTime? EspFinalExitUtc { get; set; }
public DateTime? DesktopArrivedUtc { get; set; }
public DateTime? HelloResolvedUtc { get; set; }
public DateTime? ImePatternSeenUtc { get; set; }
public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
public List<string> SignalsSeen { get; set; } = new List<string>();
}
}
@@ -47,6 +47,74 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
}
}
/// <summary>
/// Called when DeviceSetup provisioning status shows all subcategories succeeded.
/// In Self-Deploying mode, this is the primary completion signal because no user session
/// means no Hello, no desktop arrival, and possibly no Shell-Core ESP exit event.
/// In non-Self-Deploying mode, this is informational only — normal paths handle completion.
/// </summary>
private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
{
_logger.Info("EnrollmentTracker: DeviceSetup provisioning completed successfully");
RecordSignal("device_setup_provisioning_complete");
_stateData.DeviceSetupProvisioningCompleteUtc = DateTime.UtcNow;
_stateDirty = true;
if (!IsSelfDeploying)
{
_logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but not Self-Deploying — " +
"using normal completion paths (ESP exit + Hello)");
EmitTraceEvent("device_setup_complete_non_self_deploying",
"DeviceSetup provisioning complete in non-Self-Deploying mode — no action, normal paths apply",
new Dictionary<string, object> { { "autopilotMode", _autopilotMode } });
return;
}
// Self-Deploying mode: this is our primary completion signal.
// No user session → no Hello, no desktop arrival, possibly no Shell-Core ESP exit.
_logger.Info("EnrollmentTracker: Self-Deploying mode + DeviceSetup provisioning complete — " +
"transitioning to FinalizingSetup and attempting completion");
// Mark ESP as seen and final exit (provisioning success implies ESP device phase completed)
if (!_espEverSeen)
{
_espEverSeen = true;
_stateData.EspEverSeen = true;
}
if (!_espFinalExitSeen)
{
_espFinalExitSeen = true;
_stateData.EspFinalExitSeen = true;
_stateData.EspFinalExitUtc = DateTime.UtcNow;
RecordSignal("self_deploying_esp_final_exit");
}
// Emit FinalizingSetup phase event
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = "ESP phase: FinalizingSetup (Self-Deploying — DeviceSetup provisioning complete, no user session expected)",
Data = new Dictionary<string, object>
{
{ "espPhase", "FinalizingSetup" },
{ "autoDetected", true },
{ "triggeredBy", "self_deploying_provisioning_complete" },
{ "autopilotMode", _autopilotMode },
{ "skipUserStatusPage", _skipUserStatusPage }
}
});
CollectDeviceInfoAtFinalizingSetup("self_deploying_provisioning_complete");
// Attempt completion — Hello guard is bypassed for Self-Deploying (IsSelfDeploying)
TryEmitEnrollmentComplete("self_deploying_provisioning_complete");
}
/// <summary>
/// Called when WhiteGlove (Pre-Provisioning) completes successfully.
/// Emits the whiteglove_complete event. The MonitoringService handles
@@ -348,6 +416,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
_skipUserStatusPage = loaded.SkipUserStatusPage;
_skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
_autopilotMode = loaded.AutopilotMode;
_stateData = loaded;
_logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
@@ -410,12 +479,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — evaluating guards " +
$"[espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, " +
$"helloCompleted={_espAndHelloTracker?.IsHelloCompleted ?? false}, helloPolicyConfigured={_espAndHelloTracker?.IsPolicyConfigured ?? false}, " +
$"enrollmentType={_enrollmentType}]");
$"enrollmentType={_enrollmentType}, autopilotMode={_autopilotMode}, isSelfDeploying={IsSelfDeploying}]");
// Hello-Check: Hello must be resolved before we can complete
// Hello-Check: Hello must be resolved before we can complete.
// Self-Deploying mode (AutopilotMode=1) has no user session, so Hello is irrelevant —
// bypass the Hello guard entirely regardless of whether WHfB policy is configured.
bool helloResolved = _espAndHelloTracker == null
|| _espAndHelloTracker.IsHelloCompleted
|| !_espAndHelloTracker.IsPolicyConfigured;
|| !_espAndHelloTracker.IsPolicyConfigured
|| IsSelfDeploying;
if (!helloResolved)
{
@@ -458,6 +530,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
if (_stateData.ImePatternSeenUtc.HasValue)
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
_emitEvent(new EnrollmentEvent
{
@@ -472,6 +546,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{
{ "completionSource", source },
{ "helloOutcome", helloOutcome },
{ "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
{ "isSelfDeploying", IsSelfDeploying },
{ "signalsSeen", _stateData.SignalsSeen },
{ "signalTimestamps", signalTimestamps },
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
@@ -521,7 +597,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
{ "enrollmentType", _enrollmentType },
{ "lastEspPhase", _lastEspPhase ?? "none" },
{ "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
{ "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }
{ "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" },
{ "autopilotMode", _autopilotMode?.ToString() ?? "unknown" },
{ "isSelfDeploying", IsSelfDeploying }
}
});
}
@@ -615,8 +693,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
// During active ESP (AccountSetup runs in background with desktop visible),
// the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
// Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
// Self-Deploying: no user session, Hello is irrelevant — skip timer entirely.
if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
&& (!_espEverSeen || _espFinalExitSeen))
&& (!_espEverSeen || _espFinalExitSeen)
&& !IsSelfDeploying)
{
_logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
_espAndHelloTracker.StartHelloWaitTimer();
@@ -167,6 +167,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
_espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
_espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
}
_espFailureTimer?.Dispose();
@@ -223,6 +224,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
if (_stateData.ImePatternSeenUtc.HasValue)
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
// Build per-phase package states: snapshots from completed phases + current phase
var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
@@ -501,6 +501,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
// Self-Deploying mode: skip Hello wait entirely — no user session for Hello
if (IsSelfDeploying)
{
_logger.Info("EnrollmentTracker: Self-Deploying mode — skipping Hello wait, proceeding to completion");
TryEmitEnrollmentComplete("ime_pattern");
return;
}
// Check if Windows Hello is configured but not yet completed
if (_espAndHelloTracker != null)
{
@@ -66,6 +66,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
private bool? _skipUserStatusPage;
private bool? _skipDeviceStatusPage;
// Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
private int? _autopilotMode;
private bool IsSelfDeploying => _autopilotMode == 1;
// Safety-net timer for waiting_for_hello state
private Timer _waitingForHelloSafetyTimer;
private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s)
@@ -190,6 +194,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_espAndHelloTracker.EspFailureDetected += OnEspFailureDetected;
_espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
}
}
@@ -298,13 +303,27 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_enrollmentType = result.enrollmentType;
_skipUserStatusPage = result.skipUserStatusPage;
_skipDeviceStatusPage = result.skipDeviceStatusPage;
_autopilotMode = result.autopilotMode;
_stateData.EnrollmentType = _enrollmentType;
_stateData.SkipUserStatusPage = _skipUserStatusPage;
_stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
_stateData.AutopilotMode = _autopilotMode;
_stateDirty = true;
_logger.Info($"EnrollmentTracker: enrollment type detected: {_enrollmentType ?? "unknown"} " +
$"(skipUserStatusPage={_skipUserStatusPage}, skipDeviceStatusPage={_skipDeviceStatusPage})");
$"(autopilotMode={_autopilotMode}, skipUserStatusPage={_skipUserStatusPage}, skipDeviceStatusPage={_skipDeviceStatusPage})");
if (IsSelfDeploying)
{
EmitTraceEvent("self_deploying_detected",
"AutopilotMode=1 (Self-Deploying) — Hello guard will be bypassed, provisioning status used as completion signal",
new Dictionary<string, object>
{
{ "autopilotMode", _autopilotMode },
{ "skipUserStatusPage", _skipUserStatusPage },
{ "enrollmentType", _enrollmentType }
});
}
}
/// <summary>
@@ -1 +1 @@
462
463