mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat: allow agent downgrade
This commit is contained in:
@@ -246,5 +246,63 @@ namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
Assert.NotNull(result.ParseError);
|
||||
Assert.False(File.Exists(_paths.CheckedMarker)); // still cleaned up
|
||||
}
|
||||
|
||||
private void WriteDowngradeBlocked(string current = "1.0.961", string latest = "1.0.951", string trigger = "runtime_hash_mismatch")
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["outcome"] = "downgrade_blocked",
|
||||
["reason"] = "downgrade_blocked",
|
||||
["currentVersion"] = current,
|
||||
["latestVersion"] = latest,
|
||||
["triggerReason"] = trigger,
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = string.Empty,
|
||||
};
|
||||
File.WriteAllText(_paths.SkippedMarker, json.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlockedMarker_FirstTime_EmitsWarningEvent()
|
||||
{
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.Equal(EventSeverity.Warning, result.Event.Severity);
|
||||
Assert.Equal("downgrade_blocked", result.Event.Data["outcome"]);
|
||||
Assert.Equal("1.0.951", result.Event.Data["latestVersion"]);
|
||||
Assert.Equal("1.0.961", result.Event.Data["currentVersion"]);
|
||||
Assert.Equal("runtime_hash_mismatch", result.Event.Data["triggerReason"]);
|
||||
Assert.Contains("downgrade from 1.0.961 to 1.0.951", result.Event.Message);
|
||||
Assert.False(result.Deduped);
|
||||
Assert.False(File.Exists(_paths.SkippedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlocked_SameSessionSameLatest_IsDeduped()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Null(result.Event);
|
||||
Assert.True(result.Deduped);
|
||||
Assert.Equal("downgrade_blocked", result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlocked_DifferentSession_EmitsFresh()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.False(result.Deduped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
/// 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; }
|
||||
/// <summary>
|
||||
/// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade).
|
||||
/// The hash seeds <see cref="SelfUpdater"/>'s trusted-channel verification; the flag decides
|
||||
/// whether a strictly older version advertised by the backend may replace the running binary.
|
||||
/// </summary>
|
||||
public Func<string, bool, Task> RuntimeSelfUpdateTriggerAsync { get; set; }
|
||||
|
||||
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion,
|
||||
string previousExitType = "first_run", string previousCrashException = null,
|
||||
@@ -578,13 +583,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
// 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;
|
||||
// Forward-only gate: admin must opt-in per tenant for a downgrade to apply.
|
||||
var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Self-update: triggering forced update due to runtime hash mismatch");
|
||||
await RuntimeSelfUpdateTriggerAsync(zipHash).ConfigureAwait(false);
|
||||
_logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})");
|
||||
await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false);
|
||||
_logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -75,8 +75,33 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
}
|
||||
else if (File.Exists(paths.SkippedMarker))
|
||||
{
|
||||
result.Event = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
|
||||
var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
|
||||
result.Outcome = skipOutcome;
|
||||
|
||||
// Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path
|
||||
// re-fires on every agent restart, and each restart would otherwise emit another
|
||||
// Warning event for the same current→latest pair. Suppress duplicates within the
|
||||
// same session when the same downgrade is advertised by the backend.
|
||||
if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal))
|
||||
{
|
||||
var latestVersion = ExtractLatestVersion(skipEvent);
|
||||
var lastEmit = TryReadLastEmit(paths.LastEmit);
|
||||
if (lastEmit != null &&
|
||||
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal))
|
||||
{
|
||||
result.Deduped = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Event = skipEvent;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Event = skipEvent;
|
||||
}
|
||||
}
|
||||
else if (File.Exists(paths.CheckedMarker))
|
||||
{
|
||||
@@ -189,6 +214,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
var latestVersion = (string)marker["latestVersion"];
|
||||
var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
|
||||
var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
|
||||
var triggerReason = (string)marker["triggerReason"];
|
||||
|
||||
// Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers
|
||||
// lying around across an upgrade, derive it from the reason.
|
||||
@@ -205,6 +231,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
};
|
||||
if (latestVersion != null)
|
||||
data["latestVersion"] = latestVersion;
|
||||
if (!string.IsNullOrEmpty(triggerReason))
|
||||
data["triggerReason"] = triggerReason;
|
||||
|
||||
string message;
|
||||
if (outcome == "check_failed")
|
||||
message = $"Agent version check failed (reason={reason})";
|
||||
else if (outcome == "downgrade_blocked")
|
||||
message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)";
|
||||
else
|
||||
message = $"Agent self-update skipped (reason={reason})";
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
@@ -213,9 +249,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
EventType = Constants.EventTypes.AgentVersionCheck,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "Agent",
|
||||
Message = outcome == "check_failed"
|
||||
? $"Agent version check failed (reason={reason})"
|
||||
: $"Agent self-update skipped (reason={reason})",
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ namespace AutopilotMonitor.Agent
|
||||
|
||||
// Try to load backend hash from cached remote-config.json (from a previous run).
|
||||
// This gives the self-updater a second trust channel for integrity verification.
|
||||
bool cachedAllowAgentDowngrade = false;
|
||||
try
|
||||
{
|
||||
var cachedConfigPath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\remote-config.json");
|
||||
@@ -117,6 +118,10 @@ namespace AutopilotMonitor.Agent
|
||||
{
|
||||
SelfUpdater.Log("Self-update: no backend integrity hash in cached config — will use version.json hash as fallback");
|
||||
}
|
||||
|
||||
cachedAllowAgentDowngrade = cachedConfig?.AllowAgentDowngrade ?? false;
|
||||
if (cachedAllowAgentDowngrade)
|
||||
SelfUpdater.Log("Self-update: cached config has AllowAgentDowngrade=true (downgrades permitted)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -128,7 +133,11 @@ namespace AutopilotMonitor.Agent
|
||||
SelfUpdater.Log($"Self-update: could not load cached config for backend hash: {ex.Message}");
|
||||
}
|
||||
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(GetAgentVersion(), agentDir, consoleMode).GetAwaiter().GetResult();
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
GetAgentVersion(),
|
||||
agentDir,
|
||||
consoleMode,
|
||||
allowDowngrade: cachedAllowAgentDowngrade).GetAwaiter().GetResult();
|
||||
// If we reach here, no update was applied — continue normal startup
|
||||
|
||||
try
|
||||
@@ -329,7 +338,7 @@ namespace AutopilotMonitor.Agent
|
||||
// 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 =>
|
||||
service.RuntimeSelfUpdateTriggerAsync = (zipHash, allowDowngrade) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(zipHash))
|
||||
SelfUpdater.BackendExpectedSha256 = zipHash;
|
||||
@@ -340,7 +349,8 @@ namespace AutopilotMonitor.Agent
|
||||
consoleMode: consoleMode,
|
||||
forceUpdate: true,
|
||||
triggerReason: "runtime_hash_mismatch",
|
||||
downloadTimeoutMsOverride: 60000);
|
||||
downloadTimeoutMsOverride: 60000,
|
||||
allowDowngrade: allowDowngrade);
|
||||
};
|
||||
|
||||
service.Start();
|
||||
|
||||
@@ -104,13 +104,20 @@ namespace AutopilotMonitor.Agent
|
||||
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
|
||||
/// and we know an update is required.
|
||||
/// </param>
|
||||
/// <param name="allowDowngrade">
|
||||
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
|
||||
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
|
||||
/// <c>version.json</c> from silently overwriting a higher-versioned dev/pre-release agent via
|
||||
/// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios.
|
||||
/// </param>
|
||||
public static async Task CheckAndApplyUpdateAsync(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
bool forceUpdate = false,
|
||||
string triggerReason = "startup",
|
||||
int? downloadTimeoutMsOverride = null)
|
||||
int? downloadTimeoutMsOverride = null,
|
||||
bool allowDowngrade = false)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
@@ -141,19 +148,54 @@ namespace AutopilotMonitor.Agent
|
||||
return; // Could not determine latest version — continue with current
|
||||
}
|
||||
|
||||
// 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})");
|
||||
if (isStartupTrigger)
|
||||
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
|
||||
return;
|
||||
}
|
||||
// Step 2: Version comparison. Three-way decision:
|
||||
// cmp < 0 → latest < current = downgrade
|
||||
// cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate)
|
||||
// cmp > 0 → latest > current = upgrade
|
||||
var cmp = CompareVersions(currentVersion, latestVersion);
|
||||
|
||||
if (forceUpdate)
|
||||
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, skipping version check");
|
||||
else
|
||||
if (cmp < 0)
|
||||
{
|
||||
// Downgrade — forward-only policy blocks this by default even when forceUpdate=true.
|
||||
// Protects dev/pre-release builds from being silently replaced by the production
|
||||
// version advertised by the prod backend (runtime_hash_mismatch force path).
|
||||
if (!allowDowngrade)
|
||||
{
|
||||
log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false");
|
||||
WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}");
|
||||
}
|
||||
else if (cmp == 0)
|
||||
{
|
||||
// Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair).
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
if (isStartupTrigger)
|
||||
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}");
|
||||
}
|
||||
else if (cmp > 0)
|
||||
{
|
||||
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// cmp == int.MinValue sentinel from CompareVersions → unparsable version string on
|
||||
// either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate.
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions");
|
||||
}
|
||||
|
||||
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
|
||||
@@ -361,12 +403,25 @@ namespace AutopilotMonitor.Agent
|
||||
/// </summary>
|
||||
private static bool IsNewerVersion(string current, string latest)
|
||||
{
|
||||
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
|
||||
return false;
|
||||
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
|
||||
return false;
|
||||
return CompareVersions(current, latest) > 0;
|
||||
}
|
||||
|
||||
return latestVer > currentVer;
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns negative when latest is older than current,
|
||||
/// zero when equal, positive when latest is newer than current. Returns
|
||||
/// <see cref="int.MinValue"/> when either side is not a parseable <see cref="Version"/>
|
||||
/// (callers must treat this as "cannot compare" rather than an ordered result).
|
||||
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
|
||||
/// System.Version cannot handle them.
|
||||
/// </summary>
|
||||
internal static int CompareVersions(string current, string latest)
|
||||
{
|
||||
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
|
||||
return int.MinValue;
|
||||
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
|
||||
return int.MinValue;
|
||||
|
||||
return latestVer.CompareTo(currentVer);
|
||||
}
|
||||
|
||||
private static string StripVersionSuffix(string version)
|
||||
@@ -650,6 +705,41 @@ namespace AutopilotMonitor.Agent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a downgrade-blocked marker so the next agent startup can emit an
|
||||
/// <c>agent_version_check</c> event with <c>outcome=downgrade_blocked</c>. Uses the
|
||||
/// existing skipped-marker channel so <see cref="Core.Monitoring.Core.VersionCheckEventBuilder"/>
|
||||
/// picks it up alongside other skip reasons. Best-effort — never throws.
|
||||
/// </summary>
|
||||
private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["outcome"] = "downgrade_blocked",
|
||||
["reason"] = "downgrade_blocked",
|
||||
["currentVersion"] = currentVersion ?? "unknown",
|
||||
["latestVersion"] = latestVersion ?? "unknown",
|
||||
["triggerReason"] = triggerReason ?? "unknown",
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = string.Empty
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker on the happy path (current version is already up to date) so
|
||||
/// the next agent startup can emit an agent_version_check event with outcome=up_to_date.
|
||||
|
||||
@@ -1 +1 @@
|
||||
960
|
||||
963
|
||||
|
||||
+2
@@ -555,6 +555,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "LatestAgentVersion", config.LatestAgentVersion ?? string.Empty },
|
||||
{ "LatestAgentSha256", config.LatestAgentSha256 ?? string.Empty },
|
||||
{ "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty },
|
||||
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
|
||||
{ "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty },
|
||||
// Rate limiting per-role settings
|
||||
{ "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute },
|
||||
@@ -604,6 +605,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LatestAgentVersion = entity.GetString("LatestAgentVersion") ?? string.Empty,
|
||||
LatestAgentSha256 = entity.GetString("LatestAgentSha256") ?? string.Empty,
|
||||
LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty,
|
||||
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
|
||||
LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty,
|
||||
// Rate limiting per-role settings
|
||||
UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120,
|
||||
|
||||
@@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 22, // StallProbe trace indices [2,3,4] + ModernDeployment EventID 100 downgrade
|
||||
ConfigVersion = 23, // AllowAgentDowngrade forward-only self-update gate
|
||||
UploadIntervalSeconds = 10,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
@@ -164,6 +164,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
},
|
||||
LatestAgentSha256 = adminConfig.LatestAgentSha256,
|
||||
LatestAgentExeSha256 = adminConfig.LatestAgentExeSha256,
|
||||
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
|
||||
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
|
||||
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
|
||||
SendTraceEvents = tenantConfig.SendTraceEvents,
|
||||
|
||||
@@ -250,6 +250,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string LatestAgentExeSha256 { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the agent's self-updater is allowed to install a version strictly lower
|
||||
/// than the one it is currently running. Default: false (forward-only updates; prevents
|
||||
/// dev builds from being silently downgraded to the production <c>version.json</c> via
|
||||
/// the <c>runtime_hash_mismatch</c> force path). Set to true only for controlled rollback
|
||||
/// scenarios — flip back to false immediately afterwards.
|
||||
/// </summary>
|
||||
public bool AllowAgentDowngrade { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Version string of the latest published bootstrap script (Install-AutopilotMonitor.ps1).
|
||||
/// Written by CI/CD pipeline and PS build scripts after bootstrap script upload.
|
||||
|
||||
@@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string LatestAgentExeSha256 { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the self-updater will install a lower version if the backend advertises one.
|
||||
/// Default: false (forward-only). Gated per tenant via the admin configuration.
|
||||
/// Only honoured by the runtime force-update path (hash mismatch) — startup version
|
||||
/// comparison never downgrades regardless of this flag.
|
||||
/// </summary>
|
||||
public bool AllowAgentDowngrade { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// NTP server address for time check during enrollment.
|
||||
/// Default: "time.windows.com"
|
||||
|
||||
@@ -30,6 +30,8 @@ interface AdminConfigContextValue {
|
||||
setMaintenanceBlockDurationHours: (value: number) => void;
|
||||
opsEventRetentionDays: number;
|
||||
setOpsEventRetentionDays: (value: number) => void;
|
||||
allowAgentDowngrade: boolean;
|
||||
setAllowAgentDowngrade: (value: boolean) => void;
|
||||
|
||||
// Diagnostics log paths
|
||||
globalDiagPaths: DiagnosticsLogPath[];
|
||||
@@ -101,6 +103,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
|
||||
const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12);
|
||||
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
|
||||
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
|
||||
|
||||
// Diagnostics Log Paths state
|
||||
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
|
||||
@@ -150,6 +153,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
|
||||
setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12);
|
||||
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
|
||||
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
|
||||
try {
|
||||
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
|
||||
} catch {
|
||||
@@ -240,6 +244,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
maxSessionWindowHours,
|
||||
maintenanceBlockDurationHours,
|
||||
opsEventRetentionDays,
|
||||
allowAgentDowngrade,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
|
||||
@@ -266,7 +271,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, getAccessToken]);
|
||||
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, getAccessToken]);
|
||||
|
||||
// Reset admin config
|
||||
const handleResetAdminConfig = useCallback(() => {
|
||||
@@ -277,6 +282,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
|
||||
setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12);
|
||||
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
|
||||
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
|
||||
try {
|
||||
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
|
||||
} catch {
|
||||
@@ -395,6 +401,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
maxSessionWindowHours, setMaxSessionWindowHours,
|
||||
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
|
||||
opsEventRetentionDays, setOpsEventRetentionDays,
|
||||
allowAgentDowngrade, setAllowAgentDowngrade,
|
||||
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
|
||||
opsAlertRules, setOpsAlertRules,
|
||||
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
|
||||
|
||||
@@ -12,6 +12,8 @@ interface AdminConfigSettingsSectionProps {
|
||||
setPlatformStatsBlobSasUrl: (value: string) => void;
|
||||
collectorIdleTimeoutMinutes: number;
|
||||
setCollectorIdleTimeoutMinutes: (value: number) => void;
|
||||
allowAgentDowngrade: boolean;
|
||||
setAllowAgentDowngrade: (value: boolean) => void;
|
||||
onSave: () => Promise<void>;
|
||||
onReset: () => void;
|
||||
}
|
||||
@@ -26,6 +28,8 @@ export function AdminConfigSettingsSection({
|
||||
setPlatformStatsBlobSasUrl,
|
||||
collectorIdleTimeoutMinutes,
|
||||
setCollectorIdleTimeoutMinutes,
|
||||
allowAgentDowngrade,
|
||||
setAllowAgentDowngrade,
|
||||
onSave,
|
||||
onReset,
|
||||
}: AdminConfigSettingsSectionProps) {
|
||||
@@ -108,6 +112,23 @@ export function AdminConfigSettingsSection({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowAgentDowngrade}
|
||||
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
|
||||
When <strong>off</strong> (default), the agent's self-updater refuses to install a version strictly lower than the one it is currently running — including via the runtime hash-mismatch force path. Protects dev/pre-release builds from being replaced by the production <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">version.json</code>. Turn on only for controlled rollback scenarios, then turn off again.
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{adminConfig && (
|
||||
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-200 dark:border-indigo-600 rounded-lg p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ export function SectionGlobalSettings() {
|
||||
globalRateLimit, setGlobalRateLimit,
|
||||
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
|
||||
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
|
||||
allowAgentDowngrade, setAllowAgentDowngrade,
|
||||
handleSaveAdminConfig, handleResetAdminConfig,
|
||||
} = useAdminConfig();
|
||||
|
||||
@@ -26,6 +27,8 @@ export function SectionGlobalSettings() {
|
||||
setPlatformStatsBlobSasUrl={setPlatformStatsBlobSasUrl}
|
||||
collectorIdleTimeoutMinutes={collectorIdleTimeoutMinutes}
|
||||
setCollectorIdleTimeoutMinutes={setCollectorIdleTimeoutMinutes}
|
||||
allowAgentDowngrade={allowAgentDowngrade}
|
||||
setAllowAgentDowngrade={setAllowAgentDowngrade}
|
||||
onSave={handleSaveAdminConfig}
|
||||
onReset={handleResetAdminConfig}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface AdminConfiguration {
|
||||
opsAlertTeamsWebhookUrl?: string;
|
||||
opsAlertSlackEnabled?: boolean;
|
||||
opsAlertSlackWebhookUrl?: string;
|
||||
allowAgentDowngrade?: boolean;
|
||||
}
|
||||
|
||||
export interface OpsAlertRule {
|
||||
|
||||
Reference in New Issue
Block a user