mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
improved agent self update flow
This commit is contained in:
@@ -104,6 +104,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
private bool _isFirstConfigApply = true;
|
||||
private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started
|
||||
|
||||
// Runtime self-update trigger (hash mismatch recovery)
|
||||
// Set by the agent host (Program.cs) to inject the SelfUpdater implementation since
|
||||
// .Core cannot reference the .Agent exe project. Once-per-process guard via Interlocked.
|
||||
private static int _integrityUpdateAttempted; // 0 = not yet, 1 = attempted
|
||||
/// <summary>
|
||||
/// Callback invoked by the runtime hash-mismatch path to trigger a forced self-update.
|
||||
/// Host wires this to <c>SelfUpdater.CheckAndApplyUpdateAsync(..., forceUpdate: true,
|
||||
/// triggerReason: "runtime_hash_mismatch", downloadTimeoutMsOverride: 60_000)</c>.
|
||||
/// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the
|
||||
/// trusted-channel integrity source.
|
||||
/// </summary>
|
||||
public Func<string, Task> RuntimeSelfUpdateTriggerAsync { get; set; }
|
||||
|
||||
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion,
|
||||
string previousExitType = "first_run", string previousCrashException = null,
|
||||
DateTime? lastBootTimeUtc = null)
|
||||
@@ -288,6 +301,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
// If the agent was self-updated on the previous run, emit a timeline event
|
||||
EmitSelfUpdateEventIfMarkerExists();
|
||||
|
||||
// If the startup self-update was skipped (network timeout etc.), emit telemetry
|
||||
// so we can measure how often this happens across the fleet.
|
||||
EmitSelfUpdateSkippedEventIfMarkerExists();
|
||||
|
||||
// Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence
|
||||
if (_deferredSecurityAuditConfigVersion.HasValue)
|
||||
{
|
||||
@@ -604,6 +621,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
_ = _emergencyReporter?.TrySendAsync(
|
||||
AgentErrorType.IntegrityCheckFailed,
|
||||
$"Binary hash mismatch: expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}...");
|
||||
|
||||
// Trigger a forced self-update on hash mismatch (once per process).
|
||||
// Runs as fire-and-forget on a background task so monitoring keeps running —
|
||||
// on success, SelfUpdater calls Environment.Exit via RestartAgent;
|
||||
// on failure we log and continue with the stale binary until next restart.
|
||||
TriggerSelfUpdateOnMismatch();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -612,6 +635,44 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the host-provided <see cref="RuntimeSelfUpdateTriggerAsync"/> delegate on a
|
||||
/// background task. Guarded by <c>_integrityUpdateAttempted</c> so we only try once
|
||||
/// per agent process (prevents download loops if the update repeatedly fails).
|
||||
/// </summary>
|
||||
private void TriggerSelfUpdateOnMismatch()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _integrityUpdateAttempted, 1) == 1)
|
||||
{
|
||||
_logger.Info("Self-update: already attempted in this process — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (RuntimeSelfUpdateTriggerAsync == null)
|
||||
{
|
||||
_logger.Warning("Self-update: no RuntimeSelfUpdateTriggerAsync wired by host — cannot recover from hash mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand the ZIP hash from the current config to the updater as trusted-channel source.
|
||||
// Null is acceptable — SelfUpdater falls back to the version.json hash.
|
||||
var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Self-update: triggering forced update due to runtime hash mismatch");
|
||||
await RuntimeSelfUpdateTriggerAsync(zipHash).ConfigureAwait(false);
|
||||
_logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Self-update: unexpected failure during forced update", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyRuntimeSettingsFromRemoteConfig()
|
||||
{
|
||||
var config = _remoteConfigService?.CurrentConfig;
|
||||
@@ -729,6 +790,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
/// <summary>
|
||||
/// Reads the self-update marker file left by SelfUpdater, emits an agent_self_updated event,
|
||||
/// and deletes the marker. Best-effort — failures are logged but never block startup.
|
||||
/// Includes phase timings, trigger reason, and a computed downtime (new process startup
|
||||
/// time minus old process exit time) so we can aggregate update duration and event-loss
|
||||
/// window across the fleet.
|
||||
/// </summary>
|
||||
private void EmitSelfUpdateEventIfMarkerExists()
|
||||
{
|
||||
@@ -739,33 +803,76 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
return;
|
||||
|
||||
var json = File.ReadAllText(markerPath);
|
||||
var marker = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
||||
if (marker == null)
|
||||
var marker = Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||
|
||||
var previousVersion = (string)marker["previousVersion"] ?? "unknown";
|
||||
var newVersion = (string)marker["newVersion"] ?? "unknown";
|
||||
var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown";
|
||||
var triggerReason = (string)marker["triggerReason"] ?? "startup";
|
||||
var exitAtUtc = (string)marker["exitAtUtc"];
|
||||
|
||||
long? ReadLong(string key)
|
||||
{
|
||||
TryDeleteMarker(markerPath);
|
||||
return;
|
||||
var token = marker[key];
|
||||
if (token == null || token.Type == Newtonsoft.Json.Linq.JTokenType.Null)
|
||||
return null;
|
||||
try { return token.ToObject<long>(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
marker.TryGetValue("previousVersion", out var previousVersion);
|
||||
marker.TryGetValue("newVersion", out var newVersion);
|
||||
marker.TryGetValue("updatedAtUtc", out var updatedAtUtc);
|
||||
var versionCheckMs = ReadLong("versionCheckMs");
|
||||
var downloadMs = ReadLong("downloadMs");
|
||||
var zipSizeBytes = ReadLong("zipSizeBytes");
|
||||
var verifyMs = ReadLong("verifyMs");
|
||||
var extractMs = ReadLong("extractMs");
|
||||
var swapMs = ReadLong("swapMs");
|
||||
var totalUpdateMs = ReadLong("totalUpdateMs");
|
||||
|
||||
_logger.Info($"Self-update detected: {previousVersion} → {newVersion}");
|
||||
// Downtime = new process start (_agentStartTimeUtc) minus old process exit.
|
||||
// Measured across the PowerShell Wait-Process gap + new agent startup up to
|
||||
// MonitoringService init. Closest proxy for "events potentially missed".
|
||||
long? downtimeMs = null;
|
||||
if (!string.IsNullOrEmpty(exitAtUtc) &&
|
||||
DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt))
|
||||
{
|
||||
downtimeMs = (long)(_agentStartTimeUtc - exitDt).TotalMilliseconds;
|
||||
}
|
||||
|
||||
_logger.Info($"Self-update detected: {previousVersion} → {newVersion} (trigger={triggerReason}, downtimeMs={downtimeMs?.ToString() ?? "n/a"})");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "previousVersion", previousVersion },
|
||||
{ "newVersion", newVersion },
|
||||
{ "updatedAtUtc", updatedAtUtc },
|
||||
{ "triggerReason", triggerReason }
|
||||
};
|
||||
if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc;
|
||||
if (versionCheckMs.HasValue) data["versionCheckMs"] = versionCheckMs.Value;
|
||||
if (downloadMs.HasValue) data["downloadMs"] = downloadMs.Value;
|
||||
if (zipSizeBytes.HasValue) data["zipSizeBytes"] = zipSizeBytes.Value;
|
||||
if (verifyMs.HasValue) data["verifyMs"] = verifyMs.Value;
|
||||
if (extractMs.HasValue) data["extractMs"] = extractMs.Value;
|
||||
if (swapMs.HasValue) data["swapMs"] = swapMs.Value;
|
||||
if (totalUpdateMs.HasValue) data["totalUpdateMs"] = totalUpdateMs.Value;
|
||||
if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value;
|
||||
|
||||
// Runtime-triggered updates are bumped to Warning so they stand out in the UI —
|
||||
// startup updates stay Info because they are the normal rollout path.
|
||||
var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal)
|
||||
? EventSeverity.Warning
|
||||
: EventSeverity.Info;
|
||||
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = Constants.EventTypes.AgentSelfUpdated,
|
||||
Severity = EventSeverity.Info,
|
||||
Severity = severity,
|
||||
Source = "Agent",
|
||||
Message = $"Agent self-updated from {previousVersion} to {newVersion}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "previousVersion", previousVersion ?? "unknown" },
|
||||
{ "newVersion", newVersion ?? "unknown" },
|
||||
{ "updatedAtUtc", updatedAtUtc ?? "unknown" }
|
||||
},
|
||||
Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
@@ -777,6 +884,60 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the self-update-skipped marker file left by SelfUpdater (startup trigger path only),
|
||||
/// emits an agent_self_update_skipped event, and deletes the marker. Best-effort — failures
|
||||
/// are logged but never block startup.
|
||||
/// </summary>
|
||||
private void EmitSelfUpdateSkippedEventIfMarkerExists()
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
|
||||
if (!File.Exists(markerPath))
|
||||
return;
|
||||
|
||||
var json = File.ReadAllText(markerPath);
|
||||
var marker = Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||
|
||||
var reason = (string)marker["reason"] ?? "unknown";
|
||||
var currentVersion = (string)marker["currentVersion"] ?? "unknown";
|
||||
var latestVersion = (string)marker["latestVersion"];
|
||||
var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
|
||||
var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
|
||||
|
||||
_logger.Warning($"Startup self-update skipped: reason={reason}, current={currentVersion}, latest={latestVersion ?? "n/a"}");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reason },
|
||||
{ "currentVersion", currentVersion },
|
||||
{ "skippedAtUtc", skippedAtUtc },
|
||||
{ "errorDetail", errorDetail }
|
||||
};
|
||||
if (latestVersion != null)
|
||||
data["latestVersion"] = latestVersion;
|
||||
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = Constants.EventTypes.AgentSelfUpdateSkipped,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "Agent",
|
||||
Message = $"Startup self-update skipped (reason={reason})",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
TryDeleteMarker(markerPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Could not process self-update-skipped marker: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteMarker(string path)
|
||||
{
|
||||
try { File.Delete(path); } catch { }
|
||||
|
||||
@@ -325,6 +325,24 @@ namespace AutopilotMonitor.Agent
|
||||
|
||||
using (var service = new MonitoringService(config, logger, agentVersion, previousExitType, previousCrashException, lastBootUtc))
|
||||
{
|
||||
// Wire the runtime self-update trigger: on hash mismatch, MonitoringService
|
||||
// invokes SelfUpdater with forceUpdate=true and a longer download timeout
|
||||
// (60s, vs. 10s at startup) because monitoring is already running and we
|
||||
// know an update is required.
|
||||
service.RuntimeSelfUpdateTriggerAsync = zipHash =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(zipHash))
|
||||
SelfUpdater.BackendExpectedSha256 = zipHash;
|
||||
|
||||
return SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: agentVersion,
|
||||
agentDir: agentDir,
|
||||
consoleMode: consoleMode,
|
||||
forceUpdate: true,
|
||||
triggerReason: "runtime_hash_mismatch",
|
||||
downloadTimeoutMsOverride: 60000);
|
||||
};
|
||||
|
||||
service.Start();
|
||||
|
||||
if (consoleMode)
|
||||
|
||||
@@ -91,7 +91,26 @@ namespace AutopilotMonitor.Agent
|
||||
/// On success, restarts the process and never returns.
|
||||
/// On any failure, returns normally so the current version continues.
|
||||
/// </summary>
|
||||
public static async Task CheckAndApplyUpdateAsync(string currentVersion, string agentDir, bool consoleMode)
|
||||
/// <param name="forceUpdate">
|
||||
/// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger
|
||||
/// to cover hotfixes that reuse the same version number.
|
||||
/// </param>
|
||||
/// <param name="triggerReason">
|
||||
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
|
||||
/// agent_self_updated event can distinguish the two paths.
|
||||
/// </param>
|
||||
/// <param name="downloadTimeoutMsOverride">
|
||||
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
|
||||
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
|
||||
/// and we know an update is required.
|
||||
/// </param>
|
||||
public static async Task CheckAndApplyUpdateAsync(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
bool forceUpdate = false,
|
||||
string triggerReason = "startup",
|
||||
int? downloadTimeoutMsOverride = null)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
@@ -99,26 +118,54 @@ namespace AutopilotMonitor.Agent
|
||||
if (consoleMode) Console.WriteLine(msg);
|
||||
};
|
||||
|
||||
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
|
||||
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
|
||||
long zipSizeBytes = 0;
|
||||
string latestVersion = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch version.json (1s timeout)
|
||||
var (latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
|
||||
if (latestVersion == null)
|
||||
return; // Could not determine latest version — continue with current
|
||||
var versionSw = Stopwatch.StartNew();
|
||||
string manifestSha256;
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
|
||||
versionSw.Stop();
|
||||
versionCheckMs = versionSw.ElapsedMilliseconds;
|
||||
|
||||
// Step 2: Compare versions
|
||||
if (!IsNewerVersion(currentVersion, latestVersion))
|
||||
if (latestVersion == null)
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
return;
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log);
|
||||
return; // Could not determine latest version — continue with current
|
||||
}
|
||||
|
||||
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
|
||||
// Step 2: Compare versions (skipped when forceUpdate=true)
|
||||
if (!forceUpdate && !IsNewerVersion(currentVersion, latestVersion))
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
return; // happy path — no skip marker
|
||||
}
|
||||
|
||||
// Step 3: Download ZIP (10s timeout)
|
||||
if (forceUpdate)
|
||||
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, skipping version check");
|
||||
else
|
||||
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
|
||||
|
||||
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
|
||||
if (!await DownloadZipAsync(zipPath, log))
|
||||
var downloadTimerSw = Stopwatch.StartNew();
|
||||
var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log);
|
||||
downloadTimerSw.Stop();
|
||||
downloadMs = downloadTimerSw.ElapsedMilliseconds;
|
||||
if (!downloadOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
|
||||
return;
|
||||
}
|
||||
try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ }
|
||||
|
||||
// Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash)
|
||||
string expectedSha256;
|
||||
@@ -137,16 +184,31 @@ namespace AutopilotMonitor.Agent
|
||||
expectedSha256 = null;
|
||||
}
|
||||
|
||||
if (!VerifyZipIntegrity(zipPath, expectedSha256, log))
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
|
||||
if (!integrityOk)
|
||||
{
|
||||
CleanupStaging(null, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Extract to staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
if (!ExtractToStaging(zipPath, stagingDir, log))
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
var extractOk = ExtractToStaging(zipPath, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Validate staging
|
||||
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
|
||||
@@ -154,13 +216,21 @@ namespace AutopilotMonitor.Agent
|
||||
{
|
||||
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Swap files (rename locked → .old, copy new)
|
||||
if (!SwapFiles(agentDir, stagingDir, log))
|
||||
var swapTimerSw = Stopwatch.StartNew();
|
||||
var swapOk = SwapFiles(agentDir, stagingDir, log);
|
||||
swapTimerSw.Stop();
|
||||
swapMs = swapTimerSw.ElapsedMilliseconds;
|
||||
if (!swapOk)
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,7 +238,19 @@ namespace AutopilotMonitor.Agent
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
|
||||
// Step 8: Write marker file so the new agent can emit an event on next startup
|
||||
WriteSelfUpdateMarker(currentVersion, latestVersion, log);
|
||||
totalSw.Stop();
|
||||
WriteSelfUpdateMarker(
|
||||
previousVersion: currentVersion,
|
||||
newVersion: latestVersion,
|
||||
triggerReason: triggerReason,
|
||||
versionCheckMs: versionCheckMs,
|
||||
downloadMs: downloadMs,
|
||||
zipSizeBytes: zipSizeBytes,
|
||||
verifyMs: verifyMs,
|
||||
extractMs: extractMs,
|
||||
swapMs: swapMs,
|
||||
totalUpdateMs: totalSw.ElapsedMilliseconds,
|
||||
log: log);
|
||||
|
||||
// Step 9: Restart via PowerShell Wait-Process
|
||||
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
|
||||
@@ -179,6 +261,10 @@ namespace AutopilotMonitor.Agent
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
|
||||
if (isStartupTrigger)
|
||||
{
|
||||
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +386,7 @@ namespace AutopilotMonitor.Agent
|
||||
/// <summary>
|
||||
/// Downloads the agent ZIP to a temp path. Returns false on failure.
|
||||
/// </summary>
|
||||
private static async Task<bool> DownloadZipAsync(string zipPath, Action<string> log)
|
||||
private static async Task<bool> DownloadZipAsync(string zipPath, int timeoutMs, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -311,7 +397,7 @@ namespace AutopilotMonitor.Agent
|
||||
File.Delete(zipPath);
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(DownloadTimeoutMs) })
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
|
||||
{
|
||||
using (var response = await client.GetAsync(zipUrl))
|
||||
{
|
||||
@@ -328,7 +414,7 @@ namespace AutopilotMonitor.Agent
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
log("Self-update: ZIP download timed out (10s) — aborting update");
|
||||
log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update");
|
||||
try { File.Delete(zipPath); } catch { }
|
||||
return false;
|
||||
}
|
||||
@@ -467,8 +553,21 @@ namespace AutopilotMonitor.Agent
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker so the next agent startup can emit an agent_self_updated event.
|
||||
/// Best-effort — failure here must not block the update.
|
||||
/// Includes phase timings, trigger reason, and exit timestamp so the downstream event
|
||||
/// can carry aggregable telemetry (downtime, total update duration, etc.).
|
||||
/// </summary>
|
||||
private static void WriteSelfUpdateMarker(string previousVersion, string newVersion, Action<string> log)
|
||||
private static void WriteSelfUpdateMarker(
|
||||
string previousVersion,
|
||||
string newVersion,
|
||||
string triggerReason,
|
||||
long versionCheckMs,
|
||||
long downloadMs,
|
||||
long zipSizeBytes,
|
||||
long verifyMs,
|
||||
long extractMs,
|
||||
long swapMs,
|
||||
long totalUpdateMs,
|
||||
Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -477,8 +576,26 @@ namespace AutopilotMonitor.Agent
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = $"{{\"previousVersion\":\"{previousVersion}\",\"newVersion\":\"{newVersion}\",\"updatedAtUtc\":\"{DateTime.UtcNow:O}\"}}";
|
||||
File.WriteAllText(markerPath, json);
|
||||
var now = DateTime.UtcNow;
|
||||
var payload = new JObject
|
||||
{
|
||||
["previousVersion"] = previousVersion ?? "unknown",
|
||||
["newVersion"] = newVersion ?? "unknown",
|
||||
["triggerReason"] = triggerReason ?? "startup",
|
||||
["updatedAtUtc"] = now.ToString("O"),
|
||||
// exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later
|
||||
// via PowerShell Wait-Process, which is close enough for downtime telemetry.
|
||||
["exitAtUtc"] = now.ToString("O"),
|
||||
["versionCheckMs"] = versionCheckMs,
|
||||
["downloadMs"] = downloadMs,
|
||||
["zipSizeBytes"] = zipSizeBytes,
|
||||
["verifyMs"] = verifyMs,
|
||||
["extractMs"] = extractMs,
|
||||
["swapMs"] = swapMs,
|
||||
["totalUpdateMs"] = totalUpdateMs
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log("Self-update: marker file written for next-startup event");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -487,6 +604,41 @@ namespace AutopilotMonitor.Agent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker so the next agent startup can emit an agent_self_update_skipped
|
||||
/// event. Only called on the startup trigger path (runtime-triggered failures are logged
|
||||
/// normally because the full logger is already up). Best-effort — never throws.
|
||||
/// </summary>
|
||||
private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var detail = errorDetail ?? string.Empty;
|
||||
if (detail.Length > 200) detail = detail.Substring(0, 200);
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["reason"] = reason ?? "unknown",
|
||||
["currentVersion"] = currentVersion ?? "unknown",
|
||||
["latestVersion"] = latestVersion, // may be null if version check failed
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = detail
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log($"Self-update: skip marker written (reason={reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write skip marker — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up staging directory and temp ZIP file.
|
||||
/// </summary>
|
||||
|
||||
@@ -1 +1 @@
|
||||
887
|
||||
889
|
||||
|
||||
@@ -35,6 +35,14 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public const string SelfUpdateMarkerFile = @"%ProgramData%\AutopilotMonitor\State\self-update-info.json";
|
||||
|
||||
/// <summary>
|
||||
/// Marker file written by SelfUpdater when the startup update path is skipped (network timeout,
|
||||
/// download failure, integrity mismatch, etc.); read by MonitoringService on next startup to emit
|
||||
/// an agent_self_update_skipped event. Only written on the startup trigger path — runtime-triggered
|
||||
/// failures are logged normally because the full logger is already up.
|
||||
/// </summary>
|
||||
public const string SelfUpdateSkippedMarkerFile = @"%ProgramData%\AutopilotMonitor\State\self-update-skipped.json";
|
||||
|
||||
/// <summary>
|
||||
/// Default path for the IME pattern match log file (debugging/diagnostics)
|
||||
/// </summary>
|
||||
@@ -168,6 +176,7 @@ namespace AutopilotMonitor.Shared
|
||||
public const string SoftwareInventoryAnalysis = "software_inventory_analysis";
|
||||
public const string VulnerabilityReport = "vulnerability_report";
|
||||
public const string AgentSelfUpdated = "agent_self_updated";
|
||||
public const string AgentSelfUpdateSkipped = "agent_self_update_skipped";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user