Upload Diagnostics package implementation

This commit is contained in:
Oliver Kieselbach
2026-02-16 19:59:57 +01:00
parent ae15d798fc
commit 897fd02fe8
10 changed files with 435 additions and 3 deletions
@@ -16,6 +16,8 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Management" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
</Project>
@@ -143,6 +143,18 @@ namespace AutopilotMonitor.Agent.Core.Configuration
/// </summary>
public int AuthFailureTimeoutMinutes { get; set; } = 0;
/// <summary>
/// Azure Blob Storage Container SAS URL for diagnostics package upload.
/// The agent uploads directly to the tenant's storage.
/// </summary>
public string DiagnosticsBlobSasUrl { get; set; }
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
/// Default: "Off"
/// </summary>
public string DiagnosticsUploadMode { get; set; } = "Off";
/// <summary>
/// Validates the configuration
/// </summary>
@@ -0,0 +1,234 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info)
/// directly to the tenant's Azure Blob Storage container via SAS URL.
/// </summary>
public class DiagnosticsPackageService
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly HttpClient _httpClient;
private static readonly string ImeLogFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
private static readonly string AgentLogFolder =
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger)
{
_configuration = configuration;
_logger = logger;
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
}
/// <summary>
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
/// Returns true if the upload succeeded, false if skipped or failed.
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
public async Task<bool> CreateAndUploadAsync(bool enrollmentSucceeded)
{
try
{
// Check if upload is needed based on configuration
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("Diagnostics upload disabled (mode=Off)");
return false;
}
if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl))
{
_logger.Debug("Diagnostics upload skipped: no Blob Storage SAS URL configured");
return false;
}
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return false;
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded})...");
// Build ZIP in memory
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}.zip";
byte[] zipBytes;
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
// 1. sessioninfo.txt
AddSessionInfo(archive, enrollmentSucceeded);
// 2. Agent logs
AddLogFiles(archive, AgentLogFolder, "AgentLogs", "*.log");
// 3. IME logs
AddLogFiles(archive, ImeLogFolder, "ImeLogs", "IntuneManagementExtension*.log");
AddLogFiles(archive, ImeLogFolder, "ImeLogs", "AppWorkload*.log");
}
zipBytes = ms.ToArray();
}
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
// Upload to Blob Storage
var uploaded = await UploadToBlobStorageAsync(zipFileName, zipBytes);
if (uploaded)
{
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
}
return uploaded;
}
catch (Exception ex)
{
_logger.Warning($"Diagnostics package creation/upload failed (non-fatal): {ex.Message}");
return false;
}
}
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
{
var sb = new StringBuilder();
sb.AppendLine($"Session ID: {_configuration.SessionId}");
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
sb.AppendLine($"Device Name: {Environment.MachineName}");
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
// Hardware info via existing DeviceInfoProvider (WMI)
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}");
sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}");
var entry = archive.CreateEntry("sessioninfo.txt");
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(sb.ToString());
}
private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern)
{
if (!Directory.Exists(sourceFolder))
{
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
return;
}
try
{
var files = Directory.GetFiles(sourceFolder, searchPattern);
foreach (var file in files)
{
try
{
var fileName = Path.GetFileName(file);
var entryName = $"{zipFolder}/{fileName}";
// Read with FileShare.ReadWrite to avoid locking conflicts with active log writers
byte[] content;
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new MemoryStream())
{
fs.CopyTo(reader);
content = reader.ToArray();
}
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
entryStream.Write(content, 0, content.Length);
_logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)");
}
catch (Exception ex)
{
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
}
}
/// <summary>
/// Uploads the ZIP bytes to the configured Azure Blob Storage container via PUT with SAS token.
/// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=...
/// Blob URL is constructed by inserting the blob name before the query string.
/// </summary>
private async Task<bool> UploadToBlobStorageAsync(string blobName, byte[] data)
{
var containerSasUrl = _configuration.DiagnosticsBlobSasUrl;
// Build blob URL: split at '?' to insert blob name
var questionMarkIndex = containerSasUrl.IndexOf('?');
string blobUrl;
if (questionMarkIndex >= 0)
{
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
var queryString = containerSasUrl.Substring(questionMarkIndex);
blobUrl = $"{basePath}/{blobName}{queryString}";
}
else
{
// No query string (unlikely for SAS but handle gracefully)
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
}
const int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
_logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
using var content = new ByteArrayContent(data);
content.Headers.Add("x-ms-blob-type", "BlockBlob");
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
var response = await _httpClient.PutAsync(blobUrl, content);
if (response.IsSuccessStatusCode)
{
return true;
}
var responseBody = await response.Content.ReadAsStringAsync();
_logger.Warning($"Blob upload attempt {attempt} failed: HTTP {(int)response.StatusCode} - {responseBody}");
}
catch (Exception ex)
{
_logger.Warning($"Blob upload attempt {attempt} failed: {ex.Message}");
}
if (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s
_logger.Info($"Retrying blob upload in {delay.TotalSeconds}s...");
await Task.Delay(delay);
}
}
_logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts");
return false;
}
}
}
@@ -49,6 +49,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Cleanup/self-destruct
private readonly CleanupService _cleanupService;
// Diagnostics package upload
private readonly DiagnosticsPackageService _diagnosticsService;
// Auth failure circuit breaker
private int _consecutiveAuthFailures = 0;
private DateTime? _firstAuthFailureTime = null;
@@ -67,6 +70,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_spool = new EventSpool(_configuration.SpoolDirectory);
_apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger);
_cleanupService = new CleanupService(_configuration, _logger);
_diagnosticsService = new DiagnosticsPackageService(_configuration, _logger);
// Subscribe to spool events for batched upload when new events arrive
_spool.EventsAvailable += OnEventsAvailable;
@@ -251,6 +255,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_configuration.RebootOnComplete = config.RebootOnComplete;
_configuration.RebootDelaySeconds = config.RebootDelaySeconds;
_configuration.MaxBatchSize = config.MaxBatchSize;
_configuration.DiagnosticsBlobSasUrl = config.DiagnosticsBlobSasUrl;
_configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode;
// Apply log level from remote config
if (Enum.TryParse<Logging.AgentLogLevel>(config.LogLevel, ignoreCase: true, out var remoteLogLevel))
@@ -529,6 +535,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Check for enrollment completion events
if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed")
{
var enrollmentSucceeded = evt.EventType == "enrollment_complete";
_logger.Info($"Enrollment completion detected: {evt.EventType}");
// Delete session ID so a new session will be created on next enrollment
@@ -559,9 +566,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Trigger self-destruct or reboot if configured
if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete)
{
Task.Run(() => HandleEnrollmentComplete());
Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded));
return; // Don't continue with normal event processing
}
// No self-destruct/reboot — still upload diagnostics if configured
Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded));
}
// Immediate upload for:
@@ -724,10 +734,66 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
}
/// <summary>
/// Uploads diagnostics package with timeline events (start + result).
/// Emits events, uploads them, then performs the actual diagnostics upload.
/// </summary>
private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
return;
if (string.IsNullOrEmpty(_configuration.DiagnosticsBlobSasUrl))
return;
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
return;
// Emit "collecting" event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "diagnostics_collecting",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Collecting diagnostics package (mode={mode})",
Data = new Dictionary<string, object>
{
{ "uploadMode", mode }
}
});
await UploadEventsAsync();
// Perform the actual ZIP + upload
var success = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded);
// Emit result event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
Severity = success ? EventSeverity.Info : EventSeverity.Warning,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = success
? "Diagnostics package uploaded successfully"
: "Diagnostics package upload failed",
Data = new Dictionary<string, object>
{
{ "uploadMode", mode }
}
});
await UploadEventsAsync();
}
/// <summary>
/// Handles enrollment completion and triggers self-destruct sequence
/// </summary>
private async Task HandleEnrollmentComplete()
private async Task HandleEnrollmentComplete(bool enrollmentSucceeded)
{
try
{
@@ -745,6 +811,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Give a moment for final upload to complete
await Task.Delay(2000);
// Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot)
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
// Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only
if (_configuration.SelfDestructOnComplete)
{
@@ -1 +1 @@
38
42
@@ -102,6 +102,8 @@ namespace AutopilotMonitor.Functions.Functions
RebootOnComplete = tenantConfig.RebootOnComplete ?? false,
RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10,
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
DiagnosticsBlobSasUrl = tenantConfig.DiagnosticsBlobSasUrl,
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
Collectors = collectors,
GatherRules = gatherRules,
ImeLogPatterns = imeLogPatterns
@@ -186,6 +186,8 @@ namespace AutopilotMonitor.Functions.Services
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "LogLevel", config.LogLevel },
{ "MaxBatchSize", config.MaxBatchSize },
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
{ "DiagnosticsUploadMode", config.DiagnosticsUploadMode },
{ "TeamsWebhookUrl", config.TeamsWebhookUrl },
{ "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess },
{ "TeamsNotifyOnFailure", config.TeamsNotifyOnFailure }
@@ -226,6 +228,8 @@ namespace AutopilotMonitor.Functions.Services
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
LogLevel = entity.GetString("LogLevel"),
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
DiagnosticsUploadMode = entity.GetString("DiagnosticsUploadMode") ?? "Off",
TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"),
TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true,
TeamsNotifyOnFailure = entity.GetBoolean("TeamsNotifyOnFailure") ?? true
@@ -91,6 +91,18 @@ namespace AutopilotMonitor.Shared.Models
/// Default: 100
/// </summary>
public int MaxBatchSize { get; set; } = 100;
/// <summary>
/// Azure Blob Storage Container SAS URL for diagnostics package upload.
/// The agent uploads directly to the tenant's storage — data never passes through the backend.
/// </summary>
public string DiagnosticsBlobSasUrl { get; set; }
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
/// Default: "Off"
/// </summary>
public string DiagnosticsUploadMode { get; set; } = "Off";
}
/// <summary>
@@ -203,6 +203,21 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int? MaxBatchSize { get; set; }
// ===== DIAGNOSTICS SETTINGS =====
/// <summary>
/// Azure Blob Storage Container SAS URL for diagnostics package upload.
/// Each tenant provides their own container — data stays in the customer's storage.
/// If null or empty, diagnostics upload is disabled.
/// </summary>
public string DiagnosticsBlobSasUrl { get; set; }
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
/// Default: "Off"
/// </summary>
public string DiagnosticsUploadMode { get; set; } = "Off";
// ===== TEAMS NOTIFICATIONS =====
/// <summary>
@@ -33,6 +33,9 @@ interface TenantConfiguration {
teamsWebhookUrl?: string;
teamsNotifyOnSuccess?: boolean;
teamsNotifyOnFailure?: boolean;
// Diagnostics package
diagnosticsBlobSasUrl?: string;
diagnosticsUploadMode?: string;
}
interface TenantAdmin {
@@ -93,6 +96,10 @@ export default function SettingsPage() {
const [teamsNotifyOnSuccess, setTeamsNotifyOnSuccess] = useState(true);
const [teamsNotifyOnFailure, setTeamsNotifyOnFailure] = useState(true);
// Diagnostics package state
const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState("");
const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off");
// Fetch configuration
useEffect(() => {
if (!tenantId) return;
@@ -139,6 +146,8 @@ export default function SettingsPage() {
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
setDiagnosticsBlobSasUrl(data.diagnosticsBlobSasUrl ?? "");
setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off");
} catch (err) {
console.error("Error fetching configuration:", err);
setError(err instanceof Error ? err.message : "Failed to load configuration");
@@ -216,6 +225,8 @@ export default function SettingsPage() {
teamsWebhookUrl: teamsWebhookUrl || undefined,
teamsNotifyOnSuccess,
teamsNotifyOnFailure,
diagnosticsBlobSasUrl: diagnosticsBlobSasUrl || undefined,
diagnosticsUploadMode,
};
const token = await getAccessToken();
@@ -386,6 +397,8 @@ export default function SettingsPage() {
setTeamsWebhookUrl(config.teamsWebhookUrl ?? "");
setTeamsNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true);
setTeamsNotifyOnFailure(config.teamsNotifyOnFailure ?? true);
setDiagnosticsBlobSasUrl(config.diagnosticsBlobSasUrl ?? "");
setDiagnosticsUploadMode(config.diagnosticsUploadMode ?? "Off");
setSuccessMessage(null);
setError(null);
};
@@ -1165,6 +1178,75 @@ export default function SettingsPage() {
</div>
</div>
{/* Diagnostics Package */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Diagnostics Package</h2>
<p className="text-sm text-gray-500 mt-1">Upload agent and IME logs as a ZIP package to your Azure Blob Storage after enrollment.</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Info */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
The agent uploads diagnostics (agent logs + IME logs) directly to <strong>your</strong> Blob Storage container. Data never passes through our backend.
</p>
</div>
{/* Blob Storage SAS URL */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Blob Storage Container SAS URL</span>
<p className="text-sm text-gray-500 mb-2">
Create an Azure Blob Storage container and generate a Container-level SAS URL with Write and Create permissions.
</p>
<div className="flex items-center gap-2">
<input
type="url"
value={diagnosticsBlobSasUrl}
onChange={(e) => setDiagnosticsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/diagnostics?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-colors font-mono text-sm"
/>
{diagnosticsBlobSasUrl && diagnosticsUploadMode !== "Off" && (
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
Active
</span>
)}
</div>
</label>
</div>
{/* Upload Mode */}
<div className={`p-4 rounded-lg border transition-colors ${diagnosticsBlobSasUrl ? 'border-gray-200 hover:border-amber-200' : 'border-gray-100 opacity-50'}`}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Upload Mode</p>
<p className="text-sm text-gray-500">Choose when diagnostics packages are uploaded</p>
</div>
<select
value={diagnosticsUploadMode}
onChange={(e) => setDiagnosticsUploadMode(e.target.value)}
disabled={!diagnosticsBlobSasUrl}
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<option value="Off">Off</option>
<option value="Always">Always</option>
<option value="OnFailure">On Failure Only</option>
</select>
</div>
</div>
</div>
</div>
{/* Serial Number Validation */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">