mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ab47de8c | |||
| f252a0709f | |||
| d6d9638981 | |||
| 020248fbda | |||
| a06a0dbfff | |||
| 96c24e0688 | |||
| c156350183 | |||
| 15b25ff509 | |||
| 5163d7d6a0 | |||
| 9fd343d328 | |||
| aef88f3337 | |||
| 12af1d6c76 | |||
| 5433bff3db | |||
| 355509aa7c |
+1
-1
Submodule internal updated: 1e6b548904...b54f43f711
@@ -300,6 +300,7 @@
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
"script_timeout_suspected",
|
||||
"script_recurrence_summary",
|
||||
"esp_phase_changed",
|
||||
"ime_agent_version",
|
||||
"ime_token_failure",
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// IME re-runs health-script policies on its own schedule for as long as the device is up.
|
||||
/// A session that waited days for its user reported every hourly run of every policy
|
||||
/// (field: 9 policies × 94 runs in one session). The gate emits the first run and every
|
||||
/// changed result, counts the rest, and keeps the start/result pairing intact that the
|
||||
/// running indicators rely on.
|
||||
/// </summary>
|
||||
public sealed class RecurringScriptGateTests
|
||||
{
|
||||
private const string Policy = "11111111-2222-3333-4444-555555555555";
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 16, 20, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static ScriptExecutionState Early(string compliance, int exit = 0, string? stderr = null) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = Policy, ScriptType = "remediation", ScriptPart = "detection",
|
||||
ComplianceResult = compliance, ExitCode = exit, Stderr = stderr, DurationBasis = "script_runtime",
|
||||
};
|
||||
|
||||
private static ScriptExecutionState Consolidated(string compliance, int exit = 0, int status = 1) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = Policy, ScriptType = "remediation", ScriptPart = "detection",
|
||||
ComplianceResult = compliance, ExitCode = exit, RemediationStatus = status,
|
||||
DurationBasis = "cycle_including_reporting_latency",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void First_run_emits_start_and_result()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
|
||||
Assert.True(gate.OnStart(Policy, T0, "6", "HS-SCRIPT-START"));
|
||||
Assert.True(gate.OnResult(Early("True"), T0.AddSeconds(5), out var released));
|
||||
Assert.Null(released);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_result_is_counted_and_its_start_stays_held()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "HS-SCRIPT-START");
|
||||
gate.OnResult(Early("True"), T0.AddSeconds(5), out _);
|
||||
|
||||
Assert.False(gate.OnStart(Policy, T0.AddHours(1), "6", "HS-SCRIPT-START"));
|
||||
Assert.False(gate.OnResult(Early("True"), T0.AddHours(1).AddSeconds(5), out var released));
|
||||
Assert.Null(released);
|
||||
|
||||
var summary = Assert.Single(gate.TakeSummaries());
|
||||
Assert.Equal(2, summary.RunsObserved);
|
||||
Assert.Equal(1, summary.RunsCollapsed);
|
||||
Assert.Equal(1, summary.CollapsedResults);
|
||||
Assert.Equal(T0.AddHours(1).AddSeconds(5), summary.LastCollapsedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_result_releases_the_held_start_with_its_own_timestamp()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "HS-SCRIPT-START");
|
||||
gate.OnResult(Early("True"), T0.AddSeconds(5), out _);
|
||||
|
||||
var secondStart = T0.AddHours(1);
|
||||
gate.OnStart(Policy, secondStart, "6", "HS-SCRIPT-START");
|
||||
Assert.True(gate.OnResult(Early("False", exit: 1), secondStart.AddSeconds(5), out var released));
|
||||
|
||||
Assert.NotNull(released);
|
||||
Assert.Equal(Policy, released.PolicyId);
|
||||
Assert.Equal(secondStart, released.SourceTimestampUtc);
|
||||
Assert.Equal("HS-SCRIPT-START", released.PatternId);
|
||||
Assert.Equal("6", released.PolicyType);
|
||||
Assert.Empty(gate.TakeSummaries());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_stderr_counts_as_a_changed_result()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p");
|
||||
Assert.True(gate.OnResult(Early("True", stderr: "Get-ChildItem : path not found"), T0.AddHours(1), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Early_signal_and_consolidated_result_do_not_overwrite_each_other()
|
||||
{
|
||||
// Both describe the detection phase, but only the consolidated one knows
|
||||
// RemediationStatus. Sharing one signature slot would make every run look changed.
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
Assert.True(gate.OnResult(Early("True"), T0, out _));
|
||||
Assert.True(gate.OnResult(Consolidated("True"), T0.AddMinutes(1), out _));
|
||||
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p");
|
||||
Assert.False(gate.OnResult(Early("True"), T0.AddHours(1), out _));
|
||||
Assert.False(gate.OnResult(Consolidated("True"), T0.AddHours(1).AddMinutes(1), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_start_without_result_is_released_only_after_the_grace()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
var heldAt = T0.AddHours(1);
|
||||
gate.OnStart(Policy, heldAt, "6", "p");
|
||||
|
||||
Assert.Empty(gate.ReleaseStartsWithoutResult(heldAt.AddMinutes(29), TimeSpan.FromMinutes(30)));
|
||||
|
||||
var released = Assert.Single(gate.ReleaseStartsWithoutResult(heldAt.AddMinutes(31), TimeSpan.FromMinutes(30)));
|
||||
Assert.Equal(heldAt, released.SourceTimestampUtc);
|
||||
Assert.Empty(gate.ReleaseStartsWithoutResult(heldAt.AddHours(2), TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_start_whose_result_was_collapsed_is_never_released_as_unfinished()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p");
|
||||
gate.OnResult(Early("True"), T0.AddHours(1), out _);
|
||||
|
||||
Assert.Empty(gate.ReleaseStartsWithoutResult(T0.AddDays(1), TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_superseded_by_the_next_start_counts_as_without_result()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p"); // no result
|
||||
gate.OnStart(Policy, T0.AddHours(2), "6", "p");
|
||||
gate.OnResult(Early("True"), T0.AddHours(2), out _);
|
||||
|
||||
var summary = Assert.Single(gate.TakeSummaries());
|
||||
Assert.Equal(3, summary.RunsObserved);
|
||||
Assert.Equal(1, summary.RunsWithoutResult);
|
||||
Assert.Equal(1, summary.RunsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summary_reports_growth_only()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p");
|
||||
gate.OnResult(Early("True"), T0.AddHours(1), out _);
|
||||
|
||||
Assert.Single(gate.TakeSummaries());
|
||||
Assert.Empty(gate.TakeSummaries());
|
||||
|
||||
gate.OnStart(Policy, T0.AddHours(2), "6", "p");
|
||||
gate.OnResult(Early("True"), T0.AddHours(2), out _);
|
||||
Assert.Equal(2, Assert.Single(gate.TakeSummaries()).RunsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_round_trips_through_json()
|
||||
{
|
||||
var gate = new RecurringScriptGate();
|
||||
gate.OnStart(Policy, T0, "6", "p");
|
||||
gate.OnResult(Early("True"), T0, out _);
|
||||
gate.OnStart(Policy, T0.AddHours(1), "6", "p");
|
||||
|
||||
var json = JsonConvert.SerializeObject(gate.ToPersisted());
|
||||
var restored = new RecurringScriptGate();
|
||||
restored.Restore(JsonConvert.DeserializeObject<Dictionary<string, RecurringScriptPolicyState>>(json));
|
||||
|
||||
// The restored gate still knows the last report and still holds the second start.
|
||||
Assert.True(restored.OnResult(Early("False", exit: 1), T0.AddHours(1).AddSeconds(5), out var released));
|
||||
Assert.Equal(T0.AddHours(1), released.SourceTimestampUtc);
|
||||
}
|
||||
|
||||
// ── Through the tracker ───────────────────────────────────────────────────
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public readonly List<ScriptStartedInfo> Started = new List<ScriptStartedInfo>();
|
||||
public readonly List<ScriptExecutionState> Completed = new List<ScriptExecutionState>();
|
||||
public readonly List<ScriptRecurrenceSummary> Summaries = new List<ScriptRecurrenceSummary>();
|
||||
public readonly ImeLogTracker Tracker;
|
||||
|
||||
public Rig(TempDirectory tmp)
|
||||
{
|
||||
Tracker = new ImeLogTracker(
|
||||
logFolder: tmp.Path,
|
||||
patterns: new List<ImeLogPattern>(),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: tmp.Path);
|
||||
Tracker.OnScriptStarted = Started.Add;
|
||||
Tracker.OnScriptCompleted = Completed.Add;
|
||||
Tracker.OnScriptRecurrenceSummary = Summaries.Add;
|
||||
}
|
||||
|
||||
public void Run(DateTime startedAtUtc, string compliance)
|
||||
{
|
||||
Tracker.HandleHealthScriptStartForTest(Policy, startedAtUtc);
|
||||
Tracker.HandleHealthScriptDetectionResultForTest(Policy, compliance, "pre", exitCode: compliance == "True" ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracker_collapses_repeats_across_a_restart_and_reports_them_on_stop()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var first = new Rig(tmp);
|
||||
first.Run(T0, "True");
|
||||
first.Run(T0.AddHours(1), "True");
|
||||
Assert.Single(first.Started);
|
||||
Assert.Single(first.Completed);
|
||||
first.Tracker.SaveStateForTest();
|
||||
|
||||
// Restart (reboot kill, WhiteGlove Part 2): the last report and the counter are back.
|
||||
var second = new Rig(tmp);
|
||||
second.Tracker.LoadStateForTest();
|
||||
second.Run(T0.AddHours(2), "True");
|
||||
Assert.Empty(second.Started);
|
||||
Assert.Empty(second.Completed);
|
||||
|
||||
second.Run(T0.AddHours(3), "False");
|
||||
var start = Assert.Single(second.Started);
|
||||
Assert.Equal(T0.AddHours(3), start.SourceTimestampUtc);
|
||||
Assert.Equal("False", Assert.Single(second.Completed).ComplianceResult);
|
||||
|
||||
second.Tracker.FlushRecurringScripts(T0.AddHours(4), shuttingDown: true);
|
||||
var summary = Assert.Single(second.Summaries);
|
||||
Assert.Equal(4, summary.RunsObserved);
|
||||
Assert.Equal(2, summary.RunsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracker_leaves_platform_scripts_alone()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var rig = new Rig(tmp);
|
||||
var observedAt = T0;
|
||||
|
||||
for (var run = 0; run < 2; run++)
|
||||
{
|
||||
rig.Tracker.SeedPendingPlatformScriptForTesting("platformA", exitCode: 0, exitObservedAtUtc: observedAt.AddMinutes(run * 10));
|
||||
rig.Tracker.CompletePlatformScriptFromImeResultForTesting("platformA", "Success", observedAt.AddMinutes(run * 10 + 1));
|
||||
}
|
||||
|
||||
Assert.Equal(2, rig.Completed.Count(s => s.ScriptType == "platform"));
|
||||
rig.Tracker.FlushRecurringScripts(T0.AddDays(1), shuttingDown: true);
|
||||
Assert.Empty(rig.Summaries);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -37,6 +37,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
[InlineData("modern_deployment_log")]
|
||||
// Environment observation — battery drain on an idle device must not reset idle clocks.
|
||||
[InlineData("power_state_change")]
|
||||
// A flapping link is environment too: it must neither keep the collectors awake nor
|
||||
// reset the stall clock of the enrollment it is stalling.
|
||||
[InlineData("network_state_change")]
|
||||
[InlineData("network_connectivity_check")]
|
||||
// Bookkeeping of health-script runs that were deliberately not reported.
|
||||
[InlineData("script_recurrence_summary")]
|
||||
// Agent health / control / transport — not device/enrollment progress (P2).
|
||||
[InlineData("collector_degraded")]
|
||||
[InlineData("telemetry_upload_poisoned")]
|
||||
@@ -67,6 +73,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.True(SignalActivityClassifier.IsRealActivity(DecisionSignalKind.InformationalEvent, payload));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Only the link-change pair is exempt. The device-info snapshots are emitted at start
|
||||
// and at the end, not per flap, and stay activity.
|
||||
[InlineData("network_interface_info")]
|
||||
[InlineData("wifi_signal_info")]
|
||||
[InlineData("network_bandwidth_estimate")]
|
||||
public void Other_network_events_remain_activity(string eventType)
|
||||
{
|
||||
var payload = new Dictionary<string, string> { [SignalPayloadKeys.EventType] = eventType };
|
||||
Assert.True(SignalActivityClassifier.IsRealActivity(DecisionSignalKind.InformationalEvent, payload));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Genuine ModernDeployment warnings/errors stay real activity — only the Info/Debug
|
||||
// `modern_deployment_log` forwarding is excluded.
|
||||
|
||||
+75
-3
@@ -252,10 +252,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// duration when the consolidated [HS] new result line arrives (the slot above is
|
||||
// cleared by the early-signal HS-COMPLIANCE handler, so timing lives in its own
|
||||
// map). Prefer the source CMTrace timestamp; latest start wins on a policy re-run.
|
||||
_healthScriptStartTimes[id] = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
_healthScriptStartTimes[id] = startedAtUtc;
|
||||
_logger.Info($"ImeLogTracker: health script started: {id}");
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "remediation", PolicyType = policyType }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
|
||||
// A replayed line of a previous enrollment bypasses the recurrence gate: the
|
||||
// adapter drops its event, and a dropped run must not count as the policy's
|
||||
// first report.
|
||||
if (!_currentLineIsHistoricReplay
|
||||
&& !_recurringScripts.OnStart(id, startedAtUtc, policyType, LastMatchedPatternId))
|
||||
{
|
||||
_stateDirty = true;
|
||||
_logger.Debug($"ImeLogTracker: health script {id} is a recurring policy — start held until its result is known");
|
||||
return;
|
||||
}
|
||||
_stateDirty = true;
|
||||
RaiseScriptStarted(new ScriptStartedInfo { PolicyId = id, ScriptType = "remediation", PolicyType = policyType });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -794,6 +806,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_pendingHealthScript = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: a health-script start line for <paramref name="id"/> stamped
|
||||
/// <paramref name="startedAtUtc"/>, without driving the regex pipeline.
|
||||
/// </summary>
|
||||
internal void HandleHealthScriptStartForTest(string id, DateTime startedAtUtc)
|
||||
{
|
||||
LastMatchedLogTimestamp = startedAtUtc;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(id ?? string.Empty, @"(?<id>.+)");
|
||||
HandleScriptStarted(match, new Dictionary<string, string> { ["scriptType"] = "remediation" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: simulates the full HS-SCRIPT-START → HS-RUN-CONTEXT / HS-EXITCODE /
|
||||
/// HS-STDOUT / HS-STDERR → HS-COMPLIANCE sequence by directly populating the
|
||||
@@ -1052,9 +1075,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private void EmitScriptEvent(ScriptExecutionState script)
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
|
||||
|
||||
// Health scripts recur for as long as the device is up; platform scripts run once.
|
||||
if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase)
|
||||
&& !_currentLineIsHistoricReplay)
|
||||
{
|
||||
var emit = _recurringScripts.OnResult(script, LastMatchedLogTimestamp ?? UtcNowProvider(), out var releasedStart);
|
||||
_stateDirty = true;
|
||||
if (!emit)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: health script {script.PolicyId} {script.ScriptPart} repeats its last reported result — counted, not emitted");
|
||||
return;
|
||||
}
|
||||
if (releasedStart != null) RaiseScriptStarted(releasedStart);
|
||||
}
|
||||
|
||||
OnScriptCompleted?.Invoke(script);
|
||||
}
|
||||
|
||||
private void RaiseScriptStarted(ScriptStartedInfo info)
|
||||
{
|
||||
try { OnScriptStarted?.Invoke(info); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A held start whose run produced no result for <see cref="HeldScriptStartGrace"/> is
|
||||
/// emitted after all: the script did not finish. On shutdown the shorter
|
||||
/// <see cref="HeldScriptStartShutdownGrace"/> applies — a start read moments before the
|
||||
/// stop stays held for the next run of this session instead of reading as unfinished.
|
||||
/// </summary>
|
||||
internal void FlushRecurringScripts(DateTime nowUtc, bool shuttingDown)
|
||||
{
|
||||
var released = _recurringScripts.ReleaseStartsWithoutResult(
|
||||
nowUtc, shuttingDown ? HeldScriptStartShutdownGrace : HeldScriptStartGrace);
|
||||
foreach (var start in released) RaiseScriptStarted(start);
|
||||
|
||||
if (shuttingDown)
|
||||
{
|
||||
foreach (var summary in _recurringScripts.TakeSummaries())
|
||||
{
|
||||
try { OnScriptRecurrenceSummary?.Invoke(summary); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptRecurrenceSummary handler threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
if (_recurringScripts.ConsumeDirty()) _stateDirty = true;
|
||||
}
|
||||
|
||||
// IME aborts a health script after roughly 30 minutes.
|
||||
internal static readonly TimeSpan HeldScriptStartGrace = TimeSpan.FromMinutes(30);
|
||||
internal static readonly TimeSpan HeldScriptStartShutdownGrace = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static string TruncateOutput(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
|
||||
|
||||
+1
@@ -551,6 +551,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// replays historic logs on purpose and bypasses the guard.
|
||||
var isStaleReplayLine = !SimulationMode && entry != null
|
||||
&& (UtcNowProvider() - NormalizeUtc(ResolveEntryUtc(entry))) > HistoricReplayThreshold;
|
||||
_currentLineIsHistoricReplay = isStaleReplayLine;
|
||||
if (isStaleReplayLine && AppMutatingActions.Contains(pattern.Action?.ToLower() ?? string.Empty))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: skipped app action '{pattern.Action}' for historic line ({ResolveEntryUtc(entry):o})");
|
||||
|
||||
@@ -192,6 +192,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// UI dedupe (dataCompleteness scoring).
|
||||
private ScriptExecutionState _pendingHealthScript;
|
||||
|
||||
// Collapses recurring health-script runs; persisted with the file positions.
|
||||
private readonly RecurringScriptGate _recurringScripts = new RecurringScriptGate();
|
||||
|
||||
// Set for the duration of one pattern dispatch: the matched line is older than
|
||||
// HistoricReplayThreshold, so the adapter drops whatever it produces.
|
||||
private bool _currentLineIsHistoricReplay;
|
||||
|
||||
// Cycle start timestamps for health (remediation) scripts, keyed by policyId. Captured
|
||||
// from the HS-SCRIPT-START line ([HS] ProcessScript) so the consolidated HS-NEW-RESULT
|
||||
// emit can surface the cycle's total run duration (start → result) on its phase events —
|
||||
@@ -498,6 +505,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public Action<ScriptStartedInfo> OnScriptStarted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the tracker stops, once per health-script policy whose repeated runs were
|
||||
/// counted instead of emitted since the last summary (see <see cref="RecurringScriptGate"/>).
|
||||
/// </summary>
|
||||
public Action<ScriptRecurrenceSummary> OnScriptRecurrenceSummary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires on every pattern match with the matched <c>PatternId</c>. Plan §4.x M4.4.4.
|
||||
/// Invoked by <c>HandlePatternMatch</c> after <see cref="LastMatchedPatternId"/> is
|
||||
@@ -994,6 +1007,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { FlushPendingPlatformScriptResults(UtcNowProvider(), force: true); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: shutdown script flush failed: {ex.Message}"); }
|
||||
|
||||
try { FlushRecurringScripts(UtcNowProvider(), shuttingDown: true); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: shutdown recurring-script flush failed: {ex.Message}"); }
|
||||
|
||||
// Final state save on shutdown
|
||||
if (_stateDirty)
|
||||
{
|
||||
@@ -1024,6 +1040,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
await CheckLogFilesAsync(token);
|
||||
FlushPendingPlatformScriptResults(UtcNowProvider());
|
||||
FlushRecurringScripts(UtcNowProvider(), shuttingDown: false);
|
||||
CheckPendingTokenFailure(UtcNowProvider());
|
||||
|
||||
var endMs = MonotonicMillisProvider();
|
||||
@@ -1163,6 +1180,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_scriptTimeoutSuspectedPosted.Add(id);
|
||||
}
|
||||
|
||||
_recurringScripts.Restore(state.RecurringScripts);
|
||||
|
||||
// Health counters + histogram: a restart (or WhiteGlove Part 2) must report the
|
||||
// whole session, not only the lines seen since the last start. Null on state files
|
||||
// written before these fields existed → counters start at zero.
|
||||
@@ -1239,6 +1258,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
PlatformScriptResultEmittedAt = new Dictionary<string, DateTime>(_platformScriptResultEmitted, StringComparer.OrdinalIgnoreCase),
|
||||
PendingPlatformScripts = _pendingPlatformScripts.Values.ToList(),
|
||||
ScriptTimeoutSuspectedPosted = _scriptTimeoutSuspectedPosted.ToList(),
|
||||
RecurringScripts = _recurringScripts.ToPersisted(),
|
||||
};
|
||||
|
||||
lock (_healthLock)
|
||||
|
||||
+5
@@ -153,6 +153,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public List<ScriptExecutionState> PendingPlatformScripts { get; set; }
|
||||
public List<string> ScriptTimeoutSuspectedPosted { get; set; }
|
||||
|
||||
// Recurring health-script runs collapsed per policy. Saved in the same write as
|
||||
// FilePositions on purpose: after a hard kill both fall back together, so re-read lines
|
||||
// are counted again instead of twice. Null on older state files → empty.
|
||||
public Dictionary<string, RecurringScriptPolicyState> RecurringScripts { get; set; }
|
||||
|
||||
// Cumulative tracker health + per-pattern histogram (ime_pattern_hits / ime_tracker_degraded,
|
||||
// 2026-08-30). Restart-safe on purpose: WhiteGlove Part 2 reports the whole session and the
|
||||
// one-shot degraded event must not fire twice. Null on older state files → zero.
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Collapses recurring health-script (proactive remediation) runs. IME re-runs every assigned
|
||||
/// policy on its own schedule for as long as the device is up; an enrollment that waits for
|
||||
/// its user for days would otherwise report every hourly run of every policy. A policy's
|
||||
/// first run and every run whose result differs from the last reported one are emitted in
|
||||
/// full; runs that repeat the last reported result are only counted and surface once per
|
||||
/// policy as a recurrence summary.
|
||||
/// <para>
|
||||
/// The start signal of an already known policy is held until its result is known, because
|
||||
/// the running indicators (web panel, session coverage) pair a start with a later result of
|
||||
/// the same policy — a start whose result was collapsed would read as "still running".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The state is part of the tracker state file and is saved in the same write as the file
|
||||
/// positions: after a hard kill both fall back together, the lines after the last save are
|
||||
/// read again and counted again — nothing is lost and nothing counts twice.
|
||||
/// </para>
|
||||
/// Not thread-safe: called from the tracker's single poll thread only.
|
||||
/// </summary>
|
||||
public sealed class RecurringScriptGate
|
||||
{
|
||||
private readonly Dictionary<string, RecurringScriptPolicyState> _policies =
|
||||
new Dictionary<string, RecurringScriptPolicyState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>True when any call changed persisted state since the last <see cref="ConsumeDirty"/>.</summary>
|
||||
private bool _dirty;
|
||||
|
||||
public bool ConsumeDirty()
|
||||
{
|
||||
var wasDirty = _dirty;
|
||||
_dirty = false;
|
||||
return wasDirty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A start line was read. Returns <c>true</c> when the start signal is emitted right away
|
||||
/// (first run of the policy); <c>false</c> when it is held until the run's result is known.
|
||||
/// </summary>
|
||||
public bool OnStart(string policyId, DateTime startedAtUtc, string policyType, string patternId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return true;
|
||||
|
||||
var isKnown = _policies.TryGetValue(policyId, out var state);
|
||||
if (!isKnown)
|
||||
{
|
||||
state = new RecurringScriptPolicyState();
|
||||
_policies[policyId] = state;
|
||||
}
|
||||
|
||||
CloseHeldRun(state);
|
||||
state.RunsObserved++;
|
||||
_dirty = true;
|
||||
|
||||
if (!isKnown) return true;
|
||||
|
||||
state.HeldStartAtUtc = startedAtUtc;
|
||||
state.HeldStartPolicyType = policyType;
|
||||
state.HeldStartPatternId = patternId;
|
||||
state.HeldRunSawResult = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A result (one phase event) is about to be emitted. Returns <c>true</c> to emit it; when
|
||||
/// the run's start was held, <paramref name="releasedStart"/> carries the start signal to
|
||||
/// emit first. Returns <c>false</c> when the result repeats the last reported one.
|
||||
/// </summary>
|
||||
public bool OnResult(ScriptExecutionState script, DateTime observedAtUtc, out ScriptStartedInfo releasedStart)
|
||||
{
|
||||
releasedStart = null;
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return true;
|
||||
|
||||
if (!_policies.TryGetValue(script.PolicyId, out var state))
|
||||
{
|
||||
state = new RecurringScriptPolicyState();
|
||||
_policies[script.PolicyId] = state;
|
||||
}
|
||||
|
||||
var key = SignatureKey(script);
|
||||
var signature = Signature(script);
|
||||
_dirty = true;
|
||||
|
||||
if (state.Signatures.TryGetValue(key, out var last) && string.Equals(last, signature, StringComparison.Ordinal))
|
||||
{
|
||||
state.CollapsedResults++;
|
||||
if (!state.FirstCollapsedAtUtc.HasValue) state.FirstCollapsedAtUtc = observedAtUtc;
|
||||
state.LastCollapsedAtUtc = observedAtUtc;
|
||||
if (state.HeldStartAtUtc.HasValue) state.HeldRunSawResult = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Signatures[key] = signature;
|
||||
releasedStart = ReleaseHeldStart(script.PolicyId, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start signals that were held for at least <paramref name="minAge"/> without any result:
|
||||
/// the script did not finish, which is exactly what the running indicators are for.
|
||||
/// </summary>
|
||||
public List<ScriptStartedInfo> ReleaseStartsWithoutResult(DateTime nowUtc, TimeSpan minAge)
|
||||
{
|
||||
var released = new List<ScriptStartedInfo>();
|
||||
foreach (var kv in _policies)
|
||||
{
|
||||
var state = kv.Value;
|
||||
if (!state.HeldStartAtUtc.HasValue || state.HeldRunSawResult) continue;
|
||||
if (nowUtc - state.HeldStartAtUtc.Value < minAge) continue;
|
||||
released.Add(ReleaseHeldStart(kv.Key, state));
|
||||
_dirty = true;
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One summary per policy whose collapsed-run count grew since the last summary. Marks
|
||||
/// the reported count, so a later stop of the same session only reports new growth.
|
||||
/// </summary>
|
||||
public List<ScriptRecurrenceSummary> TakeSummaries()
|
||||
{
|
||||
var summaries = new List<ScriptRecurrenceSummary>();
|
||||
foreach (var kv in _policies)
|
||||
{
|
||||
var state = kv.Value;
|
||||
var collapsedRuns = state.RunsCollapsed + (state.HeldStartAtUtc.HasValue && state.HeldRunSawResult ? 1 : 0);
|
||||
if (collapsedRuns <= state.RunsCollapsedAtLastSummary) continue;
|
||||
|
||||
summaries.Add(new ScriptRecurrenceSummary
|
||||
{
|
||||
PolicyId = kv.Key,
|
||||
RunsObserved = state.RunsObserved,
|
||||
RunsCollapsed = collapsedRuns,
|
||||
RunsWithoutResult = state.RunsWithoutResult,
|
||||
CollapsedResults = state.CollapsedResults,
|
||||
FirstCollapsedAtUtc = state.FirstCollapsedAtUtc,
|
||||
LastCollapsedAtUtc = state.LastCollapsedAtUtc,
|
||||
});
|
||||
state.RunsCollapsedAtLastSummary = collapsedRuns;
|
||||
_dirty = true;
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public Dictionary<string, RecurringScriptPolicyState> ToPersisted() =>
|
||||
new Dictionary<string, RecurringScriptPolicyState>(_policies, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Restore(Dictionary<string, RecurringScriptPolicyState> persisted)
|
||||
{
|
||||
_policies.Clear();
|
||||
if (persisted == null) return;
|
||||
foreach (var kv in persisted)
|
||||
{
|
||||
if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) continue;
|
||||
if (kv.Value.Signatures == null)
|
||||
kv.Value.Signatures = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
_policies[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// A held run ends when the next start of the policy arrives: all its results repeated the
|
||||
// last report (collapsed), or it never produced one.
|
||||
private static void CloseHeldRun(RecurringScriptPolicyState state)
|
||||
{
|
||||
if (!state.HeldStartAtUtc.HasValue) return;
|
||||
if (state.HeldRunSawResult) state.RunsCollapsed++;
|
||||
else state.RunsWithoutResult++;
|
||||
ClearHeldStart(state);
|
||||
}
|
||||
|
||||
private static ScriptStartedInfo ReleaseHeldStart(string policyId, RecurringScriptPolicyState state)
|
||||
{
|
||||
if (!state.HeldStartAtUtc.HasValue) return null;
|
||||
var info = new ScriptStartedInfo
|
||||
{
|
||||
PolicyId = policyId,
|
||||
ScriptType = "remediation",
|
||||
PolicyType = state.HeldStartPolicyType,
|
||||
SourceTimestampUtc = state.HeldStartAtUtc,
|
||||
PatternId = state.HeldStartPatternId,
|
||||
};
|
||||
ClearHeldStart(state);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static void ClearHeldStart(RecurringScriptPolicyState state)
|
||||
{
|
||||
state.HeldStartAtUtc = null;
|
||||
state.HeldStartPolicyType = null;
|
||||
state.HeldStartPatternId = null;
|
||||
state.HeldRunSawResult = false;
|
||||
}
|
||||
|
||||
// The early compliance signal and the consolidated result of one phase differ in
|
||||
// completeness (only the latter knows RemediationStatus); keyed apart, or the two would
|
||||
// overwrite each other's signature on every run and nothing would ever collapse.
|
||||
private static string SignatureKey(ScriptExecutionState script) =>
|
||||
(script.ScriptPart ?? string.Empty) + "|" + (script.DurationBasis ?? string.Empty);
|
||||
|
||||
private static string Signature(ScriptExecutionState script)
|
||||
{
|
||||
var culture = CultureInfo.InvariantCulture;
|
||||
return string.Join("|",
|
||||
script.ComplianceResult ?? string.Empty,
|
||||
script.ExitCode.HasValue ? script.ExitCode.Value.ToString(culture) : string.Empty,
|
||||
string.IsNullOrWhiteSpace(script.Stderr) ? "0" : "1",
|
||||
script.RemediationStatus.HasValue ? script.RemediationStatus.Value.ToString(culture) : string.Empty,
|
||||
script.ErrorCode.HasValue ? script.ErrorCode.Value.ToString(culture) : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persisted per-policy state of <see cref="RecurringScriptGate"/>.</summary>
|
||||
public class RecurringScriptPolicyState
|
||||
{
|
||||
/// <summary>Last reported result per phase and signal kind.</summary>
|
||||
public Dictionary<string, string> Signatures { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Start lines read for the policy.</summary>
|
||||
public int RunsObserved { get; set; }
|
||||
|
||||
/// <summary>Runs whose every result repeated the last reported one.</summary>
|
||||
public int RunsCollapsed { get; set; }
|
||||
|
||||
/// <summary>Runs that were followed by the next start without any result in between.</summary>
|
||||
public int RunsWithoutResult { get; set; }
|
||||
|
||||
/// <summary>Phase events that were not emitted.</summary>
|
||||
public int CollapsedResults { get; set; }
|
||||
|
||||
public DateTime? FirstCollapsedAtUtc { get; set; }
|
||||
public DateTime? LastCollapsedAtUtc { get; set; }
|
||||
|
||||
public DateTime? HeldStartAtUtc { get; set; }
|
||||
public string HeldStartPolicyType { get; set; }
|
||||
public string HeldStartPatternId { get; set; }
|
||||
public bool HeldRunSawResult { get; set; }
|
||||
|
||||
public int RunsCollapsedAtLastSummary { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="RecurringScriptGate"/> collapsed for one policy.</summary>
|
||||
public class ScriptRecurrenceSummary
|
||||
{
|
||||
public string PolicyId { get; set; }
|
||||
public int RunsObserved { get; set; }
|
||||
public int RunsCollapsed { get; set; }
|
||||
public int RunsWithoutResult { get; set; }
|
||||
public int CollapsedResults { get; set; }
|
||||
public DateTime? FirstCollapsedAtUtc { get; set; }
|
||||
public DateTime? LastCollapsedAtUtc { get; set; }
|
||||
}
|
||||
}
|
||||
+10
@@ -142,4 +142,14 @@ public class ScriptStartedInfo
|
||||
|
||||
/// <summary>Numeric IME PolicyType from the start line (captured as string), e.g. "6" for health scripts.</summary>
|
||||
public string PolicyType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source timestamp of the start line when the signal is emitted later than it was read
|
||||
/// (a held start of a recurring health script, see <see cref="RecurringScriptGate"/>). Null
|
||||
/// for a start emitted while its own line is the tracker's last matched one.
|
||||
/// </summary>
|
||||
public System.DateTime? SourceTimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Pattern id of the start line; set together with <see cref="SourceTimestampUtc"/>.</summary>
|
||||
public string PatternId { get; set; }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// detection — the exact scenario the power watcher exists to observe.
|
||||
SharedConstants.EventTypes.PowerStateChange,
|
||||
|
||||
// Same for the network link: a dock or access point that flaps for hours (field:
|
||||
// 288 lost/connected pairs in 11 h, each with its connectivity check) is not
|
||||
// enrollment progress. Counted as activity it kept the periodic collectors running
|
||||
// for the whole episode and hid the stall it was causing, because every flap reset
|
||||
// the stall clock. Progress that follows a reconnect arrives as its own download,
|
||||
// app or phase event.
|
||||
SharedConstants.EventTypes.NetworkStateChange,
|
||||
SharedConstants.EventTypes.NetworkConnectivityCheck,
|
||||
|
||||
// Same reasoning for the system timeline watcher: a clock step, a wake from standby,
|
||||
// or a backfilled episode at agent start is environment observation and must not
|
||||
// reset the idle/stall clocks.
|
||||
@@ -81,6 +90,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// says something about the agent, not about the device's enrollment progress.
|
||||
SharedConstants.EventTypes.ImeTrackerDegraded,
|
||||
SharedConstants.EventTypes.ImePatternHits,
|
||||
// Bookkeeping of runs that were deliberately not reported as activity.
|
||||
SharedConstants.EventTypes.ScriptRecurrenceSummary,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -94,6 +94,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
private readonly Action<ScriptRecurrenceSummary>? _prevOnScriptRecurrenceSummary;
|
||||
private readonly Action<ScriptRecurrenceSummary> _ourOnScriptRecurrenceSummary;
|
||||
private readonly Action<string, string> _ourOnImeTokenFailure;
|
||||
|
||||
// Dedup state for DecisionSignals.
|
||||
@@ -195,6 +197,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
|
||||
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
|
||||
_prevOnScriptStarted = _tracker.OnScriptStarted;
|
||||
_prevOnScriptRecurrenceSummary = _tracker.OnScriptRecurrenceSummary;
|
||||
_prevOnImeTokenFailure = _tracker.OnImeTokenFailure;
|
||||
_prevOnTrackerDegraded = _tracker.OnTrackerDegraded;
|
||||
|
||||
@@ -209,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
|
||||
_ourOnScriptCompleted = OnScriptCompleted;
|
||||
_ourOnScriptStarted = OnScriptStarted;
|
||||
_ourOnScriptRecurrenceSummary = OnScriptRecurrenceSummary;
|
||||
_ourOnImeTokenFailure = OnImeTokenFailure;
|
||||
|
||||
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
|
||||
@@ -219,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
|
||||
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
|
||||
_tracker.OnScriptStarted = _ourOnScriptStarted;
|
||||
_tracker.OnScriptRecurrenceSummary = _ourOnScriptRecurrenceSummary;
|
||||
_tracker.OnImeTokenFailure = _ourOnImeTokenFailure;
|
||||
_tracker.OnTrackerDegraded = _ourOnTrackerDegraded;
|
||||
}
|
||||
@@ -245,6 +250,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
|
||||
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
|
||||
_tracker.OnScriptStarted = _prevOnScriptStarted;
|
||||
if (ReferenceEquals(_tracker.OnScriptRecurrenceSummary, _ourOnScriptRecurrenceSummary))
|
||||
_tracker.OnScriptRecurrenceSummary = _prevOnScriptRecurrenceSummary;
|
||||
if (ReferenceEquals(_tracker.OnImeTokenFailure, _ourOnImeTokenFailure))
|
||||
_tracker.OnImeTokenFailure = _prevOnImeTokenFailure;
|
||||
}
|
||||
@@ -297,6 +304,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitScriptStarted(info);
|
||||
}
|
||||
|
||||
private void OnScriptRecurrenceSummary(ScriptRecurrenceSummary summary)
|
||||
{
|
||||
_prevOnScriptRecurrenceSummary?.Invoke(summary);
|
||||
EmitScriptRecurrenceSummary(summary);
|
||||
}
|
||||
|
||||
private void OnImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
_prevOnImeTokenFailure?.Invoke(errorCode, message);
|
||||
@@ -1464,10 +1477,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
};
|
||||
if (!string.IsNullOrEmpty(info.ScriptType)) data["scriptType"] = info.ScriptType!;
|
||||
if (!string.IsNullOrEmpty(info.PolicyType)) data["policyType"] = info.PolicyType!;
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
// A held start of a recurring health script arrives with its own line's stamp and
|
||||
// pattern — by now the tracker's "last matched" line is the result line.
|
||||
var wasHeld = info.SourceTimestampUtc.HasValue;
|
||||
var patternId = wasHeld ? info.PatternId : _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
var now = ResolveOccurredAt(info.SourceTimestampUtc, out var derivedFromClock, out var rawSourceTs);
|
||||
|
||||
// Historic replay — suppressing here also kills the immediateUpload flood the
|
||||
// replayed batch caused (session eaf3d8c4: 3× ingress_backpressure).
|
||||
@@ -1485,13 +1501,42 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
message: $"{label} {shortId}: started",
|
||||
// Live UI indicator — flush immediately so the running card appears within seconds,
|
||||
// not at the next batch boundary (which would defeat the purpose of a live signal).
|
||||
immediateUpload: true,
|
||||
// A held start is no longer live and rides the normal batch.
|
||||
immediateUpload: !wasHeld,
|
||||
data: data,
|
||||
occurredAtUtc: now);
|
||||
|
||||
_logger?.Debug($"ImeAdapter: script started policyId={shortId} type={info.ScriptType ?? "?"} policyType={info.PolicyType ?? "?"}");
|
||||
}
|
||||
|
||||
private void EmitScriptRecurrenceSummary(ScriptRecurrenceSummary summary)
|
||||
{
|
||||
if (summary == null || string.IsNullOrEmpty(summary.PolicyId)) return;
|
||||
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["policyId"] = summary.PolicyId,
|
||||
["scriptType"] = "remediation",
|
||||
["runsObserved"] = summary.RunsObserved.ToString(culture),
|
||||
["runsCollapsed"] = summary.RunsCollapsed.ToString(culture),
|
||||
["runsWithoutResult"] = summary.RunsWithoutResult.ToString(culture),
|
||||
["collapsedResults"] = summary.CollapsedResults.ToString(culture),
|
||||
};
|
||||
if (summary.FirstCollapsedAtUtc.HasValue)
|
||||
data["firstCollapsedAt"] = NormalizeUtc(summary.FirstCollapsedAtUtc.Value).ToString("o", culture);
|
||||
if (summary.LastCollapsedAtUtc.HasValue)
|
||||
data["lastCollapsedAt"] = NormalizeUtc(summary.LastCollapsedAtUtc.Value).ToString("o", culture);
|
||||
|
||||
var shortId = summary.PolicyId.Length >= 8 ? summary.PolicyId.Substring(0, 8) : summary.PolicyId;
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.ScriptRecurrenceSummary,
|
||||
source: SourceLabel,
|
||||
message: $"Health script {shortId}: {summary.RunsCollapsed} further run(s) repeated the last reported result",
|
||||
severity: EventSeverity.Info,
|
||||
data: data);
|
||||
}
|
||||
|
||||
private static bool IsRemediation(string? scriptType) =>
|
||||
string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for moving the backup job, row-restore and queued-run bodies off their
|
||||
/// private <c>WriteJsonAsync(req, status, object)</c> wrappers onto <c>ResponseHelper</c>.
|
||||
/// Two things changed at once and both are pinned here: the anonymous 202 bodies became DTOs,
|
||||
/// and the typed bodies left <see cref="BackupManifestJson.SerializerOptions"/> for the worker
|
||||
/// pipeline (<see cref="ApiJsonOptions"/>). The two option sets differ only in how they READ
|
||||
/// enums (<c>allowIntegerValues</c>); the facts below keep the WRITE side honest if either set
|
||||
/// ever gains a naming policy, a key policy or a converter.
|
||||
/// </summary>
|
||||
public class BackupWireParityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
|
||||
|
||||
private static JsonElement Json(string raw)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static void AssertSameUnderBothOptionSets(object body)
|
||||
{
|
||||
var backupSurface = JsonSerializer.Serialize(body, body.GetType(), BackupManifestJson.SerializerOptions);
|
||||
var pipeline = JsonSerializer.Serialize(body, body.GetType(), WireOptions);
|
||||
Assert.Equal(backupSurface, pipeline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackupJobStatus_serializes_identically_under_both_option_sets()
|
||||
{
|
||||
var t = new DateTime(2026, 9, 21, 4, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertSameUnderBothOptionSets(new BackupJobStatus
|
||||
{
|
||||
JobId = "0f8fad5bd9cb469fa16570867728950e",
|
||||
Kind = BackupJobKind.RestoreTable,
|
||||
State = BackupJobState.BlockedTerminal,
|
||||
RequestedBy = "admin@contoso.example",
|
||||
QueuedAtUtc = t,
|
||||
StartedAtUtc = t.AddSeconds(3),
|
||||
CompletedAtUtc = t.AddMinutes(2),
|
||||
LastHeartbeatUtc = t.AddMinutes(2),
|
||||
BackupId = "20260921T040000Z_a1b2c3d4",
|
||||
SourceBackupId = "20260920T040000Z_e5f6a7b8",
|
||||
TableName = "TenantConfiguration",
|
||||
Strategy = "upsert-only",
|
||||
Progress = "{\"phase\":\"copy\",\"rows\":12}",
|
||||
Error = "IntegrityCheckFailed",
|
||||
BackupOutcome = BackupOutcome.Partial,
|
||||
});
|
||||
|
||||
// Freshly queued: every nullable slot absent on both sides.
|
||||
AssertSameUnderBothOptionSets(new BackupJobStatus
|
||||
{
|
||||
JobId = "0f8fad5bd9cb469fa16570867728950e",
|
||||
Kind = BackupJobKind.Backup,
|
||||
State = BackupJobState.Queued,
|
||||
RequestedBy = "admin@contoso.example",
|
||||
QueuedAtUtc = t,
|
||||
LastHeartbeatUtc = t,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRowPreviewResponse_serializes_identically_under_both_option_sets()
|
||||
{
|
||||
var text = new RestoreRowPropertySnapshot { EdmType = "Edm.String", Value = Json("\"Pro\"") };
|
||||
var flag = new RestoreRowPropertySnapshot { EdmType = "Edm.Boolean", Value = Json("true") };
|
||||
var number = new RestoreRowPropertySnapshot { EdmType = "Edm.Int64", Value = Json("\"9007199254740993\"") };
|
||||
|
||||
AssertSameUnderBothOptionSets(new RestoreRowPreviewResponse
|
||||
{
|
||||
BackupId = "20260921T040000Z_a1b2c3d4",
|
||||
TableName = "TenantConfiguration",
|
||||
PartitionKey = "tenant/with+odd%chars",
|
||||
RowKey = "config",
|
||||
// Case-faithful column names: a key policy on either side would rewrite them.
|
||||
BackupProperties = { ["PlanTier"] = text, ["IsEnabled"] = flag, ["lowerCaseColumn"] = number },
|
||||
CurrentProperties = new Dictionary<string, RestoreRowPropertySnapshot> { ["PlanTier"] = text },
|
||||
Diff =
|
||||
{
|
||||
new RestoreRowPropertyDiff { Name = "IsEnabled", Kind = RestoreRowDiffKind.Added, Backup = flag },
|
||||
new RestoreRowPropertyDiff { Name = "PlanTier", Kind = RestoreRowDiffKind.Unchanged, Backup = text, Current = text },
|
||||
},
|
||||
RowSha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
CurrentETag = "W/\"datetime'2026-09-21T04%3A00%3A00Z'\"",
|
||||
IsAuthTable = true,
|
||||
});
|
||||
|
||||
// Live row absent: currentProperties and currentETag vanish on both sides.
|
||||
AssertSameUnderBothOptionSets(new RestoreRowPreviewResponse
|
||||
{
|
||||
BackupId = "20260921T040000Z_a1b2c3d4",
|
||||
TableName = "TenantConfiguration",
|
||||
PartitionKey = "pk",
|
||||
RowKey = "rk",
|
||||
BackupProperties = { ["PlanTier"] = text },
|
||||
Diff = { new RestoreRowPropertyDiff { Name = "PlanTier", Kind = RestoreRowDiffKind.Added, Backup = text } },
|
||||
RowSha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRowCommitResponse_serializes_identically_under_both_option_sets()
|
||||
{
|
||||
AssertSameUnderBothOptionSets(new RestoreRowCommitResponse
|
||||
{
|
||||
BackupId = "20260921T040000Z_a1b2c3d4",
|
||||
TableName = "TenantConfiguration",
|
||||
PartitionKey = "pk",
|
||||
RowKey = "rk",
|
||||
Outcome = RestoreRowCommitOutcome.Replaced,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackupTriggerResponse_matches_the_anonymous_202_body()
|
||||
{
|
||||
const string jobId = "0f8fad5bd9cb469fa16570867728950e";
|
||||
var old = new { jobId, statusUrl = $"/api/global/backups/jobs/{jobId}" };
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(old, old.GetType(), BackupManifestJson.SerializerOptions),
|
||||
JsonSerializer.Serialize(
|
||||
new BackupTriggerResponse { JobId = jobId, StatusUrl = $"/api/global/backups/jobs/{jobId}" },
|
||||
WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionDeletionMaintenanceTriggerResponse_matches_the_anonymous_202_body()
|
||||
{
|
||||
var old = new { message = "Maintenance run queued.", triggeredBy = "admin@contoso.example" };
|
||||
|
||||
// The old wrapper serialized with DEFAULT options; its keys were camelCase by hand.
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(old, old.GetType()),
|
||||
JsonSerializer.Serialize(
|
||||
new SessionDeletionMaintenanceTriggerResponse { Message = "Maintenance run queued.", TriggeredBy = "admin@contoso.example" },
|
||||
WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delegation_acknowledgements_match_the_anonymous_message_body()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { message = "Delegation ended" },
|
||||
new MessageResponse { Message = "Delegation ended" });
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,8 @@ public sealed class BootstrapRouteFailClosedTests
|
||||
|
||||
reqMock.SetupGet(r => r.Headers).Returns(headers);
|
||||
reqMock.SetupGet(r => r.Url).Returns(new Uri(url));
|
||||
// Query is virtual: without CallBase a loose mock answers null instead of parsing Url.
|
||||
reqMock.SetupGet(r => r.Query).CallBase();
|
||||
return reqMock.Object;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,96 @@ public class ImeMsiArchiverTests
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Archive already holds the version before the walk (operator backfill of a
|
||||
// build no host serves any more). The 409 path can never be reached for it —
|
||||
// without the pre-walk check the row stays Failed:VersionMismatch forever.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_VersionAlreadyInArchive_NoHostServesIt_ArchivedWithoutDownload()
|
||||
{
|
||||
var archivedPayload = new byte[1024];
|
||||
new Random(11).NextBytes(archivedPayload);
|
||||
var expectedSha = Convert.ToHexString(SHA256.HashData(archivedPayload)).ToLowerInvariant();
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ArchivedMsiExists = true,
|
||||
ArchivedPayload = archivedPayload,
|
||||
DefaultProductVersion = "9.9.9.0", // every host has moved on
|
||||
};
|
||||
archiver.ProductVersionByUrl[RecordingArchiver.ArchivedOrigin] = Version;
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
|
||||
// The row must describe the archived bytes; the source URL of a backfill is unknown.
|
||||
Assert.Equal(expectedSha, result.Sha256);
|
||||
Assert.Equal(archivedPayload.Length, result.SizeBytes);
|
||||
Assert.Null(result.SourceUrl);
|
||||
Assert.Equal(0, archiver.DownloadCalls);
|
||||
Assert.Equal(0, archiver.UploadCalls);
|
||||
Assert.Equal(0, archiver.ProvenanceCalls); // sidecar came with the backfill
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_VersionAlreadyInArchive_MissingSidecar_IsWrittenWithoutSourceUrl()
|
||||
{
|
||||
var archiver = new RecordingArchiver { ArchivedMsiExists = true, ProvenanceExists = false };
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0, archiver.DownloadCalls);
|
||||
Assert.Equal(1, archiver.ProvenanceCalls);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(archiver.ProvenanceLastJson);
|
||||
Assert.Equal(System.Text.Json.JsonValueKind.Null, doc.RootElement.GetProperty("url").ValueKind);
|
||||
// A null source must not compare equal to a null event URL.
|
||||
Assert.False(doc.RootElement.GetProperty("urlFromEvent").GetBoolean());
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("candidates").GetArrayLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.103.101.0")] // another ring's build filed under this version
|
||||
[InlineData(null)] // not a readable package
|
||||
public async Task ArchiveAsync_ArchiveEntryIsNotTheObservedVersion_NotAccepted_WalkRuns(string? archivedProductVersion)
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ArchivedMsiExists = true,
|
||||
DefaultProductVersion = "9.9.9.0",
|
||||
};
|
||||
archiver.ProductVersionByUrl[RecordingArchiver.ArchivedOrigin] = archivedProductVersion;
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedVersionMismatch, result.Status);
|
||||
Assert.Equal(ImeMsiArchiver.BuildCandidateUrls(EventUrl).Count, archiver.DownloadCalls);
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ArchiveCheckFails_RetryableError_NoDownload()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ArchivedMsiExists = true,
|
||||
ArchivedOpenException = new RequestFailedException(500, "read failed"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(0, archiver.DownloadCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provenance-write gap (daily review 2026-08-18): MSI upload is write-once,
|
||||
// so a first attempt that dies between the MSI and the provenance upload
|
||||
@@ -660,6 +750,10 @@ public class ImeMsiArchiverTests
|
||||
public Exception? UploadException { get; set; }
|
||||
public Exception? ProvenanceException { get; set; }
|
||||
public bool ProvenanceExists { get; set; } = true;
|
||||
/// <summary>Whether the archive already holds an installer for the version BEFORE the walk (operator backfill).</summary>
|
||||
public bool ArchivedMsiExists { get; set; }
|
||||
/// <summary>Key into <see cref="ProductVersionByUrl"/> for the archived bytes — they have no URL of their own.</summary>
|
||||
public const string ArchivedOrigin = "archive://existing";
|
||||
public byte[]? ArchivedPayload { get; set; }
|
||||
public Exception? ArchivedOpenException { get; set; }
|
||||
|
||||
@@ -722,12 +816,15 @@ public class ImeMsiArchiverTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(ProvenanceExists);
|
||||
protected override Task<bool> ArchiveBlobExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(blobPath.EndsWith("provenance.json", StringComparison.Ordinal)
|
||||
? ProvenanceExists
|
||||
: ArchivedMsiExists);
|
||||
|
||||
protected override Task<Stream> OpenArchivedMsiAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ArchivedOpenException is not null) throw ArchivedOpenException;
|
||||
_currentUrl = ArchivedOrigin;
|
||||
return Task.FromResult<Stream>(new MemoryStream(ArchivedPayload ?? Payload));
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -116,6 +116,53 @@ public class MaintenanceServiceExcessiveEventAutoActionTests
|
||||
Assert.False(MaintenanceService.IsAutoActionEligible(status));
|
||||
}
|
||||
|
||||
// ── Recount gate ──────────────────────────────────────────────────────────
|
||||
// The stored counter doubles on replayed batches, so every warn and every block is
|
||||
// decided on a recount. The gate keeps that Events-partition scan to candidates that
|
||||
// still have something to fire.
|
||||
|
||||
[Fact]
|
||||
public void HasPendingAction_WarnNotYetFired_IsPending()
|
||||
{
|
||||
Assert.True(MaintenanceService.HasPendingExcessiveEventAction(
|
||||
storedEventCount: 2001, SessionStatus.Succeeded, alreadyAlerted: false, alreadyAutoActioned: false,
|
||||
warnThreshold: 2000, autoMode: "Off", autoThreshold: 2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingAction_BlockNotYetFiredOnRunningSession_IsPending()
|
||||
{
|
||||
Assert.True(MaintenanceService.HasPendingExcessiveEventAction(
|
||||
storedEventCount: 2501, SessionStatus.InProgress, alreadyAlerted: true, alreadyAutoActioned: false,
|
||||
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingAction_FinishedSessionAboveBlockThreshold_IsNotRescannedForever()
|
||||
{
|
||||
// Warned long ago, can never be blocked any more: without the eligibility term this
|
||||
// row would cost a full partition scan on every sweep until retention removes it.
|
||||
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
|
||||
storedEventCount: 9000, SessionStatus.Incomplete, alreadyAlerted: true, alreadyAutoActioned: false,
|
||||
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingAction_BothAlreadyFired_IsNotPending()
|
||||
{
|
||||
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
|
||||
storedEventCount: 9000, SessionStatus.InProgress, alreadyAlerted: true, alreadyAutoActioned: true,
|
||||
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPendingAction_WarnDisabledAndModeOff_IsNotPending()
|
||||
{
|
||||
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
|
||||
storedEventCount: 9000, SessionStatus.InProgress, alreadyAlerted: false, alreadyAutoActioned: false,
|
||||
warnThreshold: 0, autoMode: "Off", autoThreshold: 2500));
|
||||
}
|
||||
|
||||
// ── Status parsing feeding that gate ──────────────────────────────────────
|
||||
// The runaway query projects a subset of columns. SessionStatus.InProgress is ordinal 0,
|
||||
// so an unparsed Status would leave every row looking in-progress and the gate above
|
||||
|
||||
@@ -71,4 +71,67 @@ public class QueryParamGuardTests
|
||||
"Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " +
|
||||
string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
// ── One source of the query collection ──────────────────────────────────────────────
|
||||
// `req.Query` IS `HttpUtility.ParseQueryString(req.Url.Query)`, cached per request (the
|
||||
// fact below pins that against a worker update). A second spelling of the same call is
|
||||
// only a pattern for the next function to copy, so the request query is read through
|
||||
// `req.Query` and ParseQueryString stays for strings that are not the request's query.
|
||||
|
||||
private static readonly Regex ParseQueryStringCall = new(@"\bParseQueryString\(", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>ParseQueryString over something other than the request query: file → why.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> ParseQueryStringBaseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Config/GetTenantConfigurationFunction.cs"] = "ShouldRedact takes the raw query string so the redaction decision is unit-testable without a request",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "query of the tenant's stored SAS URL",
|
||||
["Services/Diagnostics/SasPermissionParser.cs"] = "query of a SAS URL",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void The_request_query_is_read_through_req_Query_only()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)
|
||||
.Select(p => Path.GetRelativePath(root, p).Replace('\\', '/'))
|
||||
.Where(rel => !rel.StartsWith("bin/", StringComparison.Ordinal) && !rel.StartsWith("obj/", StringComparison.Ordinal))
|
||||
.OrderBy(p => p, StringComparer.Ordinal);
|
||||
|
||||
foreach (var rel in files)
|
||||
{
|
||||
if (!ParseQueryStringCall.IsMatch(File.ReadAllText(Path.Combine(root, rel)))) continue;
|
||||
seen.Add(rel);
|
||||
if (!ParseQueryStringBaseline.ContainsKey(rel)) offenders.Add(rel);
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"HttpUtility.ParseQueryString in the Functions project — read the request query through " +
|
||||
"req.Query, or add the file to the baseline with what it parses instead:\n " + string.Join("\n ", offenders));
|
||||
|
||||
var stale = ParseQueryStringBaseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries without a ParseQueryString any more — remove them:\n " + string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Req_Query_is_ParseQueryString_over_the_request_url()
|
||||
{
|
||||
var url = new Uri("https://localhost/api/x?a=1&a=2&b=%20x%2B&c&=orphan&D=Case");
|
||||
var req = new Moq.Mock<Microsoft.Azure.Functions.Worker.Http.HttpRequestData>(
|
||||
Moq.Mock.Of<Microsoft.Azure.Functions.Worker.FunctionContext>()) { CallBase = true };
|
||||
req.SetupGet(r => r.Url).Returns(url);
|
||||
|
||||
var viaRequest = req.Object.Query;
|
||||
var viaHelper = System.Web.HttpUtility.ParseQueryString(url.Query);
|
||||
|
||||
Assert.Equal(viaHelper.AllKeys, viaRequest.AllKeys);
|
||||
foreach (var key in viaHelper.AllKeys)
|
||||
Assert.Equal(viaHelper.GetValues(key), viaRequest.GetValues(key));
|
||||
Assert.Equal("1,2", viaRequest["a"]); // repeated key: comma-joined on both paths
|
||||
Assert.Equal(" x+", viaRequest["b"]);
|
||||
Assert.Same(viaRequest, req.Object.Query); // parsed once per request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,8 @@ public class SessionAnnotationFunctionsTests
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
|
||||
Mock.Get(req).SetupGet(r => r.Url)
|
||||
.Returns(new Uri("https://localhost/api/global/session-annotations"));
|
||||
// Query is virtual: without CallBase a loose mock answers null instead of parsing Url.
|
||||
Mock.Get(req).SetupGet(r => r.Query).CallBase();
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ public sealed class SharedManifestParityTests
|
||||
["apiErrorCodes"] = ConstStrings(typeof(Constants.ApiErrorCodes))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegationCodes)))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegatedSlots)))
|
||||
.Concat(ConstStrings(typeof(Constants.BackupErrorCodes)))
|
||||
.ToArray(),
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
|
||||
@@ -53,14 +53,16 @@ public class TypedResponseGuardTests
|
||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
|
||||
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
|
||||
// Regexes A/B only see the INLINE literal `...(new { ... })`. Four bypass shapes let a
|
||||
// response ship untyped anyway and are ratcheted here with EMPTY baselines:
|
||||
// C: the literal parked in a variable first (`var result = new { ... };` →
|
||||
// `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration,
|
||||
// D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`),
|
||||
// E: builder methods declared `object` / `Task<object>` returning `new { ... }`
|
||||
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
|
||||
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
|
||||
// (verdict-calibration's Build),
|
||||
// F: local response wrappers taking the body as `object` (`WriteJsonAsync(req, status,
|
||||
// object body)`) — every call site behind one is invisible to A–D, which is how the
|
||||
// backup, session-restore and delegation bodies stayed anonymous until 2026-09-21.
|
||||
|
||||
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
|
||||
private static readonly Regex WriteAsJsonIdentifier =
|
||||
@@ -72,6 +74,15 @@ public class TypedResponseGuardTests
|
||||
private static readonly Regex ObjectReturningBuilder =
|
||||
new(@"\b(?:object|Task<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex ObjectBodyResponseWrapper =
|
||||
new(@"\b(?:Task<HttpResponseData>|HttpResponseData)\s+\w+\s*\([^)]*\bobject\??\s+\w+[^)]*\)", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// EMPTY. A response writer takes <c>T : IApiResponse</c> / <c>T : IApiErrorResponse</c>
|
||||
/// (ResponseHelper), never <c>object</c>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> ObjectWrapperBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
|
||||
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -140,6 +151,15 @@ public class TypedResponseGuardTests
|
||||
"object/Task<object>-returning Build*/Compute*/*Payload/*Response method");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_wrappers_taking_an_object_body_are_flagged()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => ObjectBodyResponseWrapper.Matches(text).Count,
|
||||
ObjectWrapperBaseline,
|
||||
"HttpResponseData-returning wrapper with an object parameter");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
|
||||
/// properties, so any base class would silently reorder JSON keys — and key order is part
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
return await req.ErrorAsync(HttpStatusCode.InternalServerError, Constants.ApiErrorCodes.InternalError, "Validator app client ID is not configured on the backend.");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var redirectUri = query["redirectUri"];
|
||||
if (string.IsNullOrWhiteSpace(redirectUri))
|
||||
{
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class CustomsArchiveQueryFunction
|
||||
public async Task<HttpResponseData> ListRuns(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/customs-archive")] HttpRequestData req)
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var filterTenantId = query["tenantId"];
|
||||
|
||||
var summaries = new Dictionary<string, CustomsArchiveRunSummary>(System.StringComparer.Ordinal);
|
||||
|
||||
+9
-16
@@ -41,7 +41,7 @@ public class DelegationSelfServiceFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/slots")] HttpRequestData req)
|
||||
{
|
||||
var usage = await _slots.GetUsageAsync(req.GetRequestContext().TenantId);
|
||||
return await OkAsync(req, DelegatedSlotManagementFunction.ToResponse(usage));
|
||||
return await req.OkAsync(DelegatedSlotManagementFunction.ToResponse(usage));
|
||||
}
|
||||
|
||||
/// <summary>GET /api/delegations/managed — the tenants the caller's tenant manages.</summary>
|
||||
@@ -66,7 +66,7 @@ public class DelegationSelfServiceFunction
|
||||
});
|
||||
}
|
||||
|
||||
return await OkAsync(req, new ManagedTenantListResponse
|
||||
return await req.OkAsync(new ManagedTenantListResponse
|
||||
{
|
||||
HomeTenantId = home,
|
||||
Slots = DelegatedSlotManagementFunction.ToResponse(view.Slots),
|
||||
@@ -88,7 +88,7 @@ public class DelegationSelfServiceFunction
|
||||
return await BadAsync(req, "a valid tenantId (GUID) is required");
|
||||
|
||||
var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Delegation ended" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Delegation ended" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Invitations ──────────────────────────────────────────────────────────────
|
||||
@@ -118,7 +118,7 @@ public class DelegationSelfServiceFunction
|
||||
HoldUntilUtc = r.HoldUntilUtc,
|
||||
});
|
||||
}
|
||||
return await OkAsync(req, new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items });
|
||||
return await req.OkAsync(new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items });
|
||||
}
|
||||
|
||||
/// <summary>POST /api/delegations/invitations — mint a single-use invitation link (token shown once). TenantAdminOrGA.</summary>
|
||||
@@ -147,7 +147,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var result = await _svc.CancelInvitationAsync(ctx.TenantId, invitationId, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Assignees (the managing tenant's own users) ──────────────────────────────
|
||||
@@ -160,7 +160,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var home = req.GetRequestContext().TenantId;
|
||||
var assignees = await _svc.ListAssigneesAsync(home);
|
||||
return await OkAsync(req, new DelegationAssigneeListResponse
|
||||
return await req.OkAsync(new DelegationAssigneeListResponse
|
||||
{
|
||||
HomeTenantId = home,
|
||||
GroupId = Constants.TenantGroupIds.ForHomeTenant(home),
|
||||
@@ -200,7 +200,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var result = await _svc.UnassignAsync(ctx.TenantId, upn, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Unassigned" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Unassigned" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Accept (the customer's tenant admin) ─────────────────────────────────────
|
||||
@@ -217,7 +217,7 @@ public class DelegationSelfServiceFunction
|
||||
if (!result.Ok)
|
||||
return await FailAsync(req, result.Failure!);
|
||||
var p = result.Value!;
|
||||
return await OkAsync(req, new DelegationAcceptPreviewResponse
|
||||
return await req.OkAsync(new DelegationAcceptPreviewResponse
|
||||
{
|
||||
HomeTenantId = p.HomeTenantId,
|
||||
HomeTenantDomain = p.HomeTenantDomain,
|
||||
@@ -245,7 +245,7 @@ public class DelegationSelfServiceFunction
|
||||
if (!result.Ok)
|
||||
return await FailAsync(req, result.Failure!);
|
||||
var o = result.Value!;
|
||||
return await OkAsync(req, new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId });
|
||||
return await req.OkAsync(new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId });
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -257,13 +257,6 @@ public class DelegationSelfServiceFunction
|
||||
return await req.ErrorAsync((HttpStatusCode)failure.Status, failure.Code, failure.Message);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> OkAsync(HttpRequestData req, object body)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadAsync(HttpRequestData req, string error)
|
||||
{
|
||||
return await req.BadRequestAsync(error);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var windowMinutes = QueryParams.Int(query["windowMinutes"], DefaultWindowMinutes, MinWindowMinutes, MaxWindowMinutes);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
@@ -40,7 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var (filterKind, tenantId) = ParseTenantFilter(query["tenantId"]);
|
||||
|
||||
if (filterKind == TenantFilterKind.Invalid)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -34,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = DateWindowPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -36,7 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = DateWindowPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -45,7 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
try
|
||||
{
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var category = query["category"];
|
||||
var filterTenantId = query["tenantId"];
|
||||
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -41,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -47,7 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsedRaw = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsedRaw.Error != null)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("appName is required");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("appName is required");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var paging = AppsAnalyticsHelper.ParseAppsPaging(query);
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("appName is required");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var scopedTenantId = query["tenantId"];
|
||||
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
|
||||
{
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("appName is required");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var scopedTenantId = query["tenantId"];
|
||||
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
try
|
||||
{
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
|
||||
var scopedTenantId = query["tenantId"];
|
||||
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
|
||||
|
||||
+4
-13
@@ -1,7 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -36,18 +36,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
var (job, _) = await _jobs.GetWithETagAsync(jobId, req.FunctionContext.CancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new { error = "JobNotFound", jobId });
|
||||
return await req.NotFoundAsync($"Backup job {jobId} not found.", Constants.BackupErrorCodes.JobNotFound);
|
||||
}
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, job);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json);
|
||||
return response;
|
||||
return await req.OkAsync(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Backup;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
}
|
||||
catch (BackupTerminalException ex)
|
||||
{
|
||||
return await WriteErrorAsync(req, HttpStatusCode.BadRequest, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
return await req.BadRequestAsync(ex.Message, ex.Code).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 3. Dispatch on mode
|
||||
@@ -71,7 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
{
|
||||
var preview = await _restoreService.PreviewRowAsync(
|
||||
backupId, body.TableName, body.PartitionKey, body.RowKey, ct).ConfigureAwait(false);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, preview).ConfigureAwait(false);
|
||||
return await req.OkAsync(preview).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -92,13 +92,13 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
_logger.LogWarning(evtEx, "RestoreRow: ops event recording failed — write itself succeeded");
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, commit).ConfigureAwait(false);
|
||||
return await req.OkAsync(commit).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (BackupTerminalException ex)
|
||||
{
|
||||
var status = MapErrorCodeToStatus(ex.Code);
|
||||
return await WriteErrorAsync(req, status, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
return await req.ErrorAsync(status, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -107,39 +107,26 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RestoreRow: unexpected failure for backupId={BackupId} table={Table}", backupId, body.TableName);
|
||||
return await WriteErrorAsync(req, HttpStatusCode.InternalServerError, "InternalError", ex.Message).ConfigureAwait(false);
|
||||
// Sanitized envelope: the raw exception text stays in the log, the correlation id is the handle.
|
||||
return await req.InternalServerErrorAsync(
|
||||
_logger, ex, $"RestoreRow backupId={backupId} table={body.TableName}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpStatusCode MapErrorCodeToStatus(string code) => code switch
|
||||
{
|
||||
"BackupNotFound" => HttpStatusCode.NotFound,
|
||||
"RowNotInBackup" => HttpStatusCode.NotFound,
|
||||
"TableNotInBackup" => HttpStatusCode.Conflict,
|
||||
"ManifestCorrupt" => HttpStatusCode.Conflict,
|
||||
"ManifestSchemaUnsupported" => HttpStatusCode.Conflict,
|
||||
"IntegrityCheckFailed" => HttpStatusCode.Conflict,
|
||||
"BlobChangedSinceValidation" => HttpStatusCode.Conflict,
|
||||
"RowChangedSinceValidation" => HttpStatusCode.Conflict,
|
||||
"CurrentRowChanged" => HttpStatusCode.Conflict,
|
||||
"MaintenanceInProgress" => HttpStatusCode.Conflict,
|
||||
"MaintenanceLeaseLost" => HttpStatusCode.Conflict,
|
||||
_ => HttpStatusCode.BadRequest,
|
||||
Constants.BackupErrorCodes.BackupNotFound => HttpStatusCode.NotFound,
|
||||
Constants.BackupErrorCodes.RowNotInBackup => HttpStatusCode.NotFound,
|
||||
Constants.BackupErrorCodes.TableNotInBackup => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.ManifestCorrupt => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.ManifestSchemaUnsupported => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.IntegrityCheckFailed => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.RowChangedSinceValidation => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.CurrentRowChanged => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.MaintenanceInProgress => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.MaintenanceLeaseLost => HttpStatusCode.Conflict,
|
||||
_ => HttpStatusCode.BadRequest,
|
||||
};
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json).ConfigureAwait(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteErrorAsync(HttpRequestData req, HttpStatusCode status, string code, string message)
|
||||
{
|
||||
return await WriteJsonAsync(req, status, new { error = code, message }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-24
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Backup.Queue;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -63,11 +63,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
if (!inserted)
|
||||
{
|
||||
// Guid-N collision (effectively impossible) — surface so the operator can retry.
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
error = "JobIdCollision",
|
||||
message = "Failed to allocate a unique jobId — please retry.",
|
||||
});
|
||||
return await req.ConflictAsync(
|
||||
"Failed to allocate a unique jobId — please retry.",
|
||||
Constants.BackupErrorCodes.JobIdCollision);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -91,28 +89,17 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
await _jobs.TryUpdateWithCasAsync(fresh, etag.Value, req.FunctionContext.CancellationToken);
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
error = "EnqueueFailed",
|
||||
message = "Failed to enqueue backup job — job status has been marked Failed.",
|
||||
jobId,
|
||||
});
|
||||
return await req.ErrorAsync(
|
||||
HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.EnqueueFailed,
|
||||
$"Failed to enqueue backup job {jobId} — job status has been marked Failed.");
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Accepted, new
|
||||
return await req.JsonAsync(HttpStatusCode.Accepted, new BackupTriggerResponse
|
||||
{
|
||||
jobId,
|
||||
statusUrl = $"/api/global/backups/jobs/{jobId}",
|
||||
JobId = jobId,
|
||||
StatusUrl = $"/api/global/backups/jobs/{jobId}",
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
return await req.UnauthorizedAsync("X-Bootstrap-Token header is required");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantId = query["tenantId"];
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
try
|
||||
{
|
||||
// Get tenantId from query parameter
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantId = query["tenantId"];
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -46,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = TenantConfigPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Optional ?agentVersion= selects the hash-oracle line like the agent's
|
||||
// X-Agent-Version header would; absent → the current (V2) line, never the
|
||||
// legacy fallback an absent header means on the agent channel.
|
||||
var agentVersion = System.Web.HttpUtility.ParseQueryString(req.Url.Query)["agentVersion"];
|
||||
var agentVersion = req.Query["agentVersion"];
|
||||
var agentMajor = string.IsNullOrWhiteSpace(agentVersion)
|
||||
? AgentConfigResolver.CurrentAgentMajor
|
||||
: AgentConfigResolver.ParseAgentMajor(agentVersion);
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var refreshRaw = query["refresh"];
|
||||
var forceRefresh = !string.IsNullOrEmpty(refreshRaw)
|
||||
&& (refreshRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || refreshRaw == "1");
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var max = QueryParams.Int(query["max"], MaxListSize, 1, MaxListSize);
|
||||
|
||||
var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max);
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -49,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
// ?tenantId= query param (GA bypass already applied).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var rawBlobName = query["blobName"];
|
||||
|
||||
if (string.IsNullOrEmpty(query["tenantId"]) || string.IsNullOrEmpty(rawBlobName))
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Diagnostics;
|
||||
@@ -57,7 +56,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
if (read.Error != null) return read.Error;
|
||||
var blobName = read.Value?.BlobName;
|
||||
if (string.IsNullOrEmpty(blobName))
|
||||
blobName = HttpUtility.ParseQueryString(req.Url.Query)["blobName"];
|
||||
blobName = req.Query["blobName"];
|
||||
|
||||
if (string.IsNullOrEmpty(blobName))
|
||||
{
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "ops/alert-webhook")] HttpRequestData req)
|
||||
{
|
||||
var configuredSecret = Environment.GetEnvironmentVariable(SecretSettingName);
|
||||
var providedSecret = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["secret"];
|
||||
var providedSecret = req.Query["secret"];
|
||||
|
||||
if (!SecretMatches(providedSecret, configuredSecret))
|
||||
{
|
||||
|
||||
+12
-22
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Backup;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -65,11 +66,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
}
|
||||
catch (LeaseHeldException)
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
error = "RunAlreadyActive",
|
||||
message = "A session-deletion maintenance run is already active — check the Session Cleanup page for progress.",
|
||||
});
|
||||
return await req.ConflictAsync(
|
||||
"A session-deletion maintenance run is already active — check the Session Cleanup page for progress.",
|
||||
Constants.ApiErrorCodes.RunAlreadyActive);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -79,27 +78,18 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "TriggerSessionDeletionMaintenance: enqueue failed (triggeredBy={TriggeredBy})", actor);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
error = "EnqueueFailed",
|
||||
message = "Failed to enqueue the maintenance run — please retry.",
|
||||
});
|
||||
return await req.ErrorAsync(
|
||||
HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.EnqueueFailed,
|
||||
"Failed to enqueue the maintenance run — please retry.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("TriggerSessionDeletionMaintenance: run queued (triggeredBy={TriggeredBy})", actor);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Accepted, new
|
||||
return await req.JsonAsync(HttpStatusCode.Accepted, new SessionDeletionMaintenanceTriggerResponse
|
||||
{
|
||||
message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.",
|
||||
triggeredBy = actor,
|
||||
Message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.",
|
||||
TriggeredBy = actor,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
await response.WriteStringAsync(JsonSerializer.Serialize(body));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
// Optional time filter: ?days=7 (default: 30, clamped to [1, 365] for consistency
|
||||
// with metrics/summary, metrics/usage, and metrics/platform).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
|
||||
+3
-3
@@ -48,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware;
|
||||
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var rawSerial = query["serialNumber"];
|
||||
var sessionId = query["sessionId"];
|
||||
|
||||
@@ -123,7 +123,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -167,7 +167,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
|
||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var locationKey = query["locationKey"];
|
||||
var country = query["country"];
|
||||
// Accept either the legacy opaque locationKey (still used by the web UI,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
_logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var limit = QueryParams.Int(query["limit"], @default: 500, min: 1, max: 2000);
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
_logger.LogInformation($"Fetching global app metrics (User: {userEmail})");
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
// Clamped to [1, 365] like the tenant variant and the other metrics endpoints — an
|
||||
// unbounded days= here was the one way to request an unlimited cross-tenant scan.
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var locationKey = query["locationKey"];
|
||||
var country = query["country"];
|
||||
// Accept either the legacy opaque locationKey (web UI pairs it with groupBy)
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
_logger.LogInformation("Fetching global geographic metrics (User: {UserEmail})", userEmail);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
try
|
||||
{
|
||||
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "");
|
||||
var qs = req.Query;
|
||||
tenantId = qs.Get("tenantId");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = QueryParams.Int(query["days"], VerdictCalibrationResponseBuilder.DefaultWindowDays, 1, VerdictCalibrationResponseBuilder.MaxWindowDays);
|
||||
var partition = "global";
|
||||
|
||||
@@ -48,7 +48,7 @@ public class MetricsSummaryFunction
|
||||
var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
|
||||
// Optional tenantId filter — when set, GA scopes the cross-tenant summary to one
|
||||
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var filterTenantId = query["tenantId"];
|
||||
if (!string.IsNullOrWhiteSpace(filterTenantId))
|
||||
{
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
// Optional tenantId query parameter: when provided, return tenant-specific metrics
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantId = query["tenantId"];
|
||||
var days = QueryParams.Int(query["days"], @default: 90, min: 1, max: 365);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware;
|
||||
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var ruleId = query["ruleId"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ruleId))
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
try
|
||||
{
|
||||
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "");
|
||||
var qs = req.Query;
|
||||
|
||||
months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -54,7 +53,7 @@ public class ProgressPortalFunction
|
||||
// access enforced by PolicyEnforcementMiddleware (catalog: TenantScoping.QueryParam).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
if (string.IsNullOrEmpty(query["tenantId"]))
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId query parameter is required");
|
||||
@@ -180,7 +179,7 @@ public class ProgressPortalFunction
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
if (string.IsNullOrEmpty(query["tenantId"]))
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId query parameter is required");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -70,7 +69,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
private async Task<HttpResponseData> QueryEvents(
|
||||
HttpRequestData req, string? tenantId, string scope, string basePath, string? filterTenantId)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var sessionId = query["sessionId"];
|
||||
var eventType = query["eventType"];
|
||||
var severity = query["severity"];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -68,7 +67,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
private async Task<HttpResponseData> QuerySessions(
|
||||
HttpRequestData req, string? tenantId, string scope, string basePath, string? filterTenantId)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
var status = query["status"];
|
||||
var startedAfter = query["startedAfter"];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -97,7 +96,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
var actualTableName = Constants.TableNames.All
|
||||
.First(t => t.Equals(tableName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var partitionKey = query["partitionKey"];
|
||||
var rowKeyPrefix = query["rowKeyPrefix"];
|
||||
var filter = query["filter"];
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -37,7 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var blobName = query["blobName"];
|
||||
|
||||
if (string.IsNullOrEmpty(blobName))
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = SessionReportsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/rules/gather")] HttpRequestData req)
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "").Get("tenantId");
|
||||
var tenantId = req.Query.Get("tenantId");
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
@@ -66,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/rules/analyze")] HttpRequestData req)
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "").Get("tenantId");
|
||||
var tenantId = req.Query.Get("tenantId");
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
@@ -211,7 +211,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// </summary>
|
||||
private static string ResolveTenantId(HttpRequestData req, out string? error)
|
||||
{
|
||||
var tenantId = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "").Get("tenantId");
|
||||
var tenantId = req.Query.Get("tenantId");
|
||||
error = string.IsNullOrEmpty(tenantId) ? "tenantId query parameter is required" : null;
|
||||
return tenantId ?? string.Empty;
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -31,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var parsed = RuleSubmissionsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null) return await req.BadRequestAsync(parsed.Error);
|
||||
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
if (!GetSessionStatsFunction.TryParseDays(query["days"], out var days, out var error))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// Authentication + authorization enforced by PolicyEnforcementMiddleware (GlobalReadOrDelegatedSubset).
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
// Delegated ("MSP") callers are admitted by the subset tier with AllowedTenantIds set; the
|
||||
// aggregate (no ?tenantId=) is then bounded to that managed subset. Null for GA/Reader = all
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -69,7 +68,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var actorEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var mode = (query["mode"] ?? "summary").ToLowerInvariant();
|
||||
if (mode != "summary" && mode != "full" && mode != "download")
|
||||
{
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -56,7 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
return await BadRequest(req, "sessionId is required");
|
||||
}
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var tenantId = query["tenantId"];
|
||||
var manifestId = query["manifestId"];
|
||||
var mode = (query["mode"] ?? "summary").ToLowerInvariant();
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -54,7 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var state = query["state"] ?? string.Empty;
|
||||
if (!IsAllowedState(state))
|
||||
{
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -36,7 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
}
|
||||
|
||||
var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var pagination = SessionEventsPagination.ParseQuery(query);
|
||||
if (pagination.Error != null)
|
||||
{
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -43,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
if (!TryParseDays(query["days"], out var days, out var error))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -32,7 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: false);
|
||||
if (parsed.Error != null)
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
// sessionId-scoped sub-tree probe — handy when the operator opens the browser by
|
||||
// following a "deletion_started" audit row (sessionId in hand, manifestId not).
|
||||
var sessionFilter = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["sessionId"];
|
||||
var sessionFilter = req.Query["sessionId"];
|
||||
|
||||
// Group entries by sessionId so the UI can render a two-level tree without extra
|
||||
// bookkeeping. SortedDictionary on the outer level keeps the order deterministic;
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ public class QuickSearchSessionsFunction
|
||||
retryAfterSeconds: rateLimitResult.RetryAfter is { } retryAfter ? (int)retryAfter.TotalSeconds : null);
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var q = query["q"]?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(q) || q.Length < 2)
|
||||
|
||||
+8
-21
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
@@ -51,7 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.BadRequest, new { success = false, message = "sessionId is required" });
|
||||
return await req.BadRequestAsync("sessionId is required");
|
||||
}
|
||||
|
||||
var read = await req.ReadAsync<RestoreSessionRequest>();
|
||||
@@ -95,12 +94,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// the tenantId in the request body (or via the X-Target-Tenant header).
|
||||
if (string.IsNullOrWhiteSpace(body.TenantId))
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new
|
||||
{
|
||||
success = false,
|
||||
message = $"Session {sessionId} not found in any active SessionsIndex entry. " +
|
||||
"If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body."
|
||||
});
|
||||
return await req.NotFoundAsync(
|
||||
$"Session {sessionId} not found in any active SessionsIndex entry.",
|
||||
hint: "If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body.");
|
||||
}
|
||||
tenantId = body.TenantId!;
|
||||
}
|
||||
@@ -133,11 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"RestoreSession: unhandled exception for tenant={TenantId} session={SessionId} manifestId={ManifestId}",
|
||||
tenantId, sessionId, body.ManifestId);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError,
|
||||
new { success = false, message = "Internal error during restore — see audit log + telemetry for details.", exceptionType = ex.GetType().Name });
|
||||
return await req.InternalServerErrorAsync(
|
||||
_logger, ex, $"RestoreSession tenant={tenantId} session={sessionId} manifestId={body.ManifestId}");
|
||||
}
|
||||
|
||||
return await WriteResultAsync(req, result);
|
||||
@@ -175,14 +168,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
DurationMs = result.DurationMs,
|
||||
};
|
||||
|
||||
return await WriteJsonAsync(req, status, body);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
// One shape for every outcome (see SessionRestoreResponse): the status carries the verdict.
|
||||
return await req.JsonAsync(status, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -36,7 +35,7 @@ public class SearchSessionsByCveFunction
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
|
||||
string? tenantId;
|
||||
string? filterTenantId;
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -36,7 +35,7 @@ public class SearchSessionsByEventFunction
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
string? tenantId;
|
||||
string? filterTenantId;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -36,7 +35,7 @@ public class SearchSessionsFunction
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
|
||||
// The endpoint binding tenantId — for tenant-scoped routes the JWT
|
||||
// is authoritative. For global routes the JWT just identifies the
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -42,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// Global Admin only — enforced by PolicyEnforcementMiddleware
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var tenantId = query["tenantId"];
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -50,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// Global Admin only — enforced by PolicyEnforcementMiddleware
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var skip = QueryParams.Int(query["skip"], @default: 0, min: 0, max: int.MaxValue);
|
||||
var take = QueryParams.Int(query["take"], DefaultTake, 1, MaxTake);
|
||||
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -54,7 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// upfront so the read works cross-tenant for any platform-scope caller.
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var rescan = string.Equals(query["rescan"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// A re-scan regenerates + stores the report — an ACTION on a MemberRead route;
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
@@ -57,7 +56,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
int cpeCommunityEntries = 0;
|
||||
string? cpeSeedError = null;
|
||||
string? cpeCommunityError = null;
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var query = req.Query;
|
||||
var reseedParam = query["reseed"]?.ToLowerInvariant();
|
||||
bool reseedSeed = reseedParam == "true" || reseedParam == "seed";
|
||||
bool reseedCommunity = reseedParam == "true" || reseedParam == "community";
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -65,7 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Diagnostics;
|
||||
@@ -48,7 +47,7 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return (tooMany, default);
|
||||
}
|
||||
|
||||
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
|
||||
var ticket = req.Query["t"];
|
||||
if (string.IsNullOrEmpty(ticket))
|
||||
{
|
||||
return (await req.BadRequestAsync("Missing download ticket."), default);
|
||||
|
||||
+13
-12
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Azure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -58,32 +59,32 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
/// </summary>
|
||||
public async Task<ValidationResult> ValidateAsync(string backupId, string tableName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException("InvalidBackupId", "backupId is required");
|
||||
if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException("InvalidTable", "tableName is required");
|
||||
if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId is required");
|
||||
if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required");
|
||||
|
||||
// 1. Manifest exists + parseable
|
||||
var (payload, _) = await _store.ReadManifestAsync(backupId, ct).ConfigureAwait(false);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BackupTerminalException("BackupNotFound", $"manifest for backupId '{backupId}' was not found");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.BackupNotFound, $"manifest for backupId '{backupId}' was not found");
|
||||
}
|
||||
|
||||
CriticalTableBackupManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<CriticalTableBackupManifest>(payload, BackupManifestJson.SerializerOptions)
|
||||
?? throw new BackupTerminalException("ManifestCorrupt", "manifest deserialised to null");
|
||||
?? throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, "manifest deserialised to null");
|
||||
}
|
||||
catch (BackupTerminalException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BackupTerminalException("ManifestCorrupt", $"manifest JSON parse failed: {ex.Message}", ex);
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, $"manifest JSON parse failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (manifest.SchemaVersion != SupportedSchemaVersion)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestSchemaUnsupported",
|
||||
Constants.BackupErrorCodes.ManifestSchemaUnsupported,
|
||||
$"manifest schemaVersion={manifest.SchemaVersion} not supported by this restore endpoint (expected {SupportedSchemaVersion})");
|
||||
}
|
||||
|
||||
@@ -92,13 +93,13 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (entry == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"TableNotInBackup",
|
||||
Constants.BackupErrorCodes.TableNotInBackup,
|
||||
$"no entry for tableName '{tableName}' in manifest {backupId}");
|
||||
}
|
||||
if (entry.Status != TableBackupStatus.Ok && entry.Status != TableBackupStatus.Empty)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"TableNotInBackup",
|
||||
Constants.BackupErrorCodes.TableNotInBackup,
|
||||
$"manifest entry for '{tableName}' has Status={entry.Status} — restore is only allowed from Ok or Empty entries");
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (!string.Equals(entry.BlobName, expectedBlobName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestCorrupt",
|
||||
Constants.BackupErrorCodes.ManifestCorrupt,
|
||||
$"manifest entry BlobName='{entry.BlobName}' does not match canonical '{expectedBlobName}'");
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (openResult == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BackupNotFound",
|
||||
Constants.BackupErrorCodes.BackupNotFound,
|
||||
$"NDJSON blob '{expectedBlobName}' is missing despite manifest entry present — orphan manifest");
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// The IfMatch precondition can also fire mid-stream if the blob was
|
||||
// overwritten between OpenRead and the final byte. Same recovery as
|
||||
// OpenNdjsonReadAsync's 412 path.
|
||||
throw new BackupTerminalException("BlobChangedSinceValidation", "NDJSON blob changed during SHA verification", ex);
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.BlobChangedSinceValidation, "NDJSON blob changed during SHA verification", ex);
|
||||
}
|
||||
|
||||
var expectedSha = (entry.Sha256Hex ?? string.Empty).ToLowerInvariant();
|
||||
@@ -146,7 +147,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
"BackupRestoreInputValidator: SHA mismatch for backupId={BackupId} table={Table} (expected={Expected}, actual={Actual}) — refusing restore",
|
||||
backupId, tableName, expectedSha, actualSha);
|
||||
throw new BackupTerminalException(
|
||||
"IntegrityCheckFailed",
|
||||
Constants.BackupErrorCodes.IntegrityCheckFailed,
|
||||
$"SHA-256 mismatch for table '{tableName}' (expected {expectedSha}, got {actualSha}) — backup may be tampered or partially written");
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BlobChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation,
|
||||
$"NDJSON blob '{BuildNdjsonBlobName(backupId, tableName)}' changed between GetProperties and OpenRead — refusing to read a half-version",
|
||||
ex);
|
||||
}
|
||||
|
||||
+10
-10
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (dump == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowNotInBackup",
|
||||
Constants.BackupErrorCodes.RowNotInBackup,
|
||||
$"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}");
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (LeaseHeldException ex)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MaintenanceInProgress",
|
||||
Constants.BackupErrorCodes.MaintenanceInProgress,
|
||||
"another maintenance operation is in progress — please retry shortly",
|
||||
ex);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (dump == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowNotInBackup",
|
||||
Constants.BackupErrorCodes.RowNotInBackup,
|
||||
$"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}");
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (!string.Equals(freshSha, (ifSha256 ?? string.Empty).ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.RowChangedSinceValidation,
|
||||
$"backup row SHA-256 changed between preview and commit (preview echoed {ifSha256}, fresh {freshSha}) — re-open the preview to refresh");
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 404)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"CurrentRowChanged",
|
||||
Constants.BackupErrorCodes.CurrentRowChanged,
|
||||
$"live row was modified or deleted since preview (status={ex.Status}) — re-open the preview",
|
||||
ex);
|
||||
}
|
||||
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (StorageErrors.IsAlreadyExists(ex))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"CurrentRowChanged",
|
||||
Constants.BackupErrorCodes.CurrentRowChanged,
|
||||
"live row was created since preview — re-open the preview",
|
||||
ex);
|
||||
}
|
||||
@@ -266,7 +266,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// up to the HTTP layer's generic catch and renders as a 500.
|
||||
var reason = holder.RenewalFailureReason ?? "handler cancelled without explicit renewal failure";
|
||||
throw new BackupTerminalException(
|
||||
"MaintenanceLeaseLost",
|
||||
Constants.BackupErrorCodes.MaintenanceLeaseLost,
|
||||
$"maintenance lease lost during commit — retry shortly ({reason})",
|
||||
ex);
|
||||
}
|
||||
@@ -287,7 +287,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (open == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BackupNotFound",
|
||||
Constants.BackupErrorCodes.BackupNotFound,
|
||||
$"NDJSON blob for {backupId}/{tableName}.ndjson disappeared between validation and scan");
|
||||
}
|
||||
if (!open.Value.ETag.Equals(expectedETag))
|
||||
@@ -296,7 +296,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// validator and scan, the blob was overwritten.
|
||||
try { open.Value.Stream.Dispose(); } catch { /* best effort */ }
|
||||
throw new BackupTerminalException(
|
||||
"BlobChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation,
|
||||
"NDJSON blob ETag changed between validation hash and scan — refusing to read mismatching bytes");
|
||||
}
|
||||
return open.Value;
|
||||
@@ -331,7 +331,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestCorrupt",
|
||||
Constants.BackupErrorCodes.ManifestCorrupt,
|
||||
$"NDJSON line failed to parse as DeletionRowDump: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
+9
-9
@@ -28,21 +28,21 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupId))
|
||||
{
|
||||
throw new BackupTerminalException("InvalidBackupId", "backupId route segment is empty");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId route segment is empty");
|
||||
}
|
||||
if (request == null)
|
||||
{
|
||||
throw new BackupTerminalException("MissingBody", "request body is required");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.MissingBody, "request body is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.TableName))
|
||||
{
|
||||
throw new BackupTerminalException("InvalidTable", "tableName is required");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required");
|
||||
}
|
||||
if (!Constants.CriticalBackupTables.All.Contains(request.TableName, StringComparer.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"InvalidTable",
|
||||
Constants.BackupErrorCodes.InvalidTable,
|
||||
$"tableName '{request.TableName}' is not in the critical-backup catalog — only the 15 critical tables are restorable");
|
||||
}
|
||||
|
||||
@@ -51,16 +51,16 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// is not found for the given keys.
|
||||
if (request.PartitionKey == null)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidKeys", "partitionKey is required (may be empty string but not null)");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "partitionKey is required (may be empty string but not null)");
|
||||
}
|
||||
if (request.RowKey == null)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidKeys", "rowKey is required (may be empty string but not null)");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "rowKey is required (may be empty string but not null)");
|
||||
}
|
||||
|
||||
if (request.Mode != RestoreRowMode.Preview && request.Mode != RestoreRowMode.Commit)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidMode", $"mode must be 'preview' or 'commit', got '{request.Mode}'");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidMode, $"mode must be 'preview' or 'commit', got '{request.Mode}'");
|
||||
}
|
||||
|
||||
if (request.Mode == RestoreRowMode.Commit)
|
||||
@@ -68,13 +68,13 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (string.IsNullOrEmpty(request.IfSha256))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MissingPrecondition",
|
||||
Constants.BackupErrorCodes.MissingPrecondition,
|
||||
"ifSha256 is required on commit — echo the rowSha256 from the preview response");
|
||||
}
|
||||
if (!IsLowerHex64(request.IfSha256))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MissingPrecondition",
|
||||
Constants.BackupErrorCodes.MissingPrecondition,
|
||||
"ifSha256 must be 64 lowercase hex characters (SHA-256 hex digest)");
|
||||
}
|
||||
// ifCurrentETag is intentionally nullable: null carries the precondition
|
||||
|
||||
@@ -202,7 +202,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
/// not permanently rejected as a bad version string), and the last archive activity is
|
||||
/// older than <see cref="RequeueBackoff"/> — or there never was any (versions sighted
|
||||
/// before the archiver existed, or archived by hand by an operator; the
|
||||
/// 409 path then heals the row to Archived).
|
||||
/// pre-walk archive check then turns the row to Archived without a download).
|
||||
/// </summary>
|
||||
public static bool ShouldRequeueOnSighting(
|
||||
ImeVersionSighting? sighting, string? msiDownloadUrl, string? msiMatchedBy, DateTime nowUtc)
|
||||
@@ -246,6 +246,13 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
|
||||
try
|
||||
{
|
||||
// The archive may already hold this version (operator backfill, earlier attempt).
|
||||
// This must be checked BEFORE the walk: the 409 below is only reachable when a
|
||||
// host still serves the build, which is never true for a version Microsoft has
|
||||
// moved past — the row would stay Failed:VersionMismatch next to its own blob.
|
||||
var existing = await TryCompleteFromArchiveAsync(envelope, version, blobPath, cancellationToken);
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var adminConfig = await GetAdminConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxImeMsiDownloadSizeMB * 1024 * 1024;
|
||||
|
||||
@@ -385,7 +392,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
ImeMsiArchiveEnvelope envelope, string version, string blobPath, string sourceUrl,
|
||||
IReadOnlyList<CandidateAttempt> attempts, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ProvenanceExistsAsync($"{version}/provenance.json", cancellationToken))
|
||||
if (await ArchiveBlobExistsAsync($"{version}/provenance.json", cancellationToken))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
|
||||
@@ -394,21 +401,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
blobPath, null, null, sourceUrl);
|
||||
}
|
||||
|
||||
string sha256;
|
||||
long totalBytes;
|
||||
string? archivedProductVersion;
|
||||
using (var archived = await OpenArchivedMsiAsync(blobPath, cancellationToken))
|
||||
using (var spool = CreateSpool())
|
||||
{
|
||||
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
|
||||
{
|
||||
await hashing.CopyToAsync(spool, cancellationToken);
|
||||
sha256 = hashing.Sha256Hex;
|
||||
totalBytes = hashing.TotalBytes;
|
||||
}
|
||||
spool.Position = 0;
|
||||
archivedProductVersion = ReadProductVersion(spool);
|
||||
}
|
||||
var (sha256, totalBytes, archivedProductVersion) = await InspectArchivedMsiAsync(blobPath, cancellationToken);
|
||||
|
||||
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, archivedProductVersion, attempts, cancellationToken);
|
||||
|
||||
@@ -419,8 +412,62 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
blobPath, sha256, totalBytes, sourceUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-walk check: when the archive already holds an installer for this version AND
|
||||
/// its ProductVersion is the observed one, the job is done without touching the CDN.
|
||||
/// Hash and size come from the archived bytes so the row describes the blob; a missing
|
||||
/// sidecar is written (no source URL — the bytes did not come from this attempt).
|
||||
/// An entry holding another build is NOT accepted: the folder invariant is "the bytes
|
||||
/// are that version", so the walk runs and the row keeps telling the truth. Returns
|
||||
/// null when there is no verified entry. Failures propagate (retryable), like the heal.
|
||||
/// </summary>
|
||||
private async Task<ImeMsiArchiveResult?> TryCompleteFromArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, string version, string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await ArchiveBlobExistsAsync(blobPath, cancellationToken)) return null;
|
||||
|
||||
var (sha256, totalBytes, archivedProductVersion) = await InspectArchivedMsiAsync(blobPath, cancellationToken);
|
||||
if (!VersionsMatch(version, archivedProductVersion))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: archive entry {BlobPath} holds ProductVersion {ProductVersion} — not the observed {Version}; not accepted, walking the hosts",
|
||||
blobPath, archivedProductVersion ?? "(unreadable)", version);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await ArchiveBlobExistsAsync($"{version}/provenance.json", cancellationToken))
|
||||
{
|
||||
await WriteProvenanceAsync(envelope, version, sourceUrl: null, sha256, totalBytes, archivedProductVersion,
|
||||
Array.Empty<CandidateAttempt>(), cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImeMsiArchiver: {Version} is already archived ({SizeBytes} bytes, sha256 {Sha256}) — no download",
|
||||
version, totalBytes, sha256);
|
||||
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
|
||||
blobPath, sha256, totalBytes, SourceUrl: null);
|
||||
}
|
||||
|
||||
/// <summary>Hash, size and ProductVersion of the installer that sits in the archive.</summary>
|
||||
private async Task<(string Sha256, long TotalBytes, string? ProductVersion)> InspectArchivedMsiAsync(
|
||||
string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var archived = await OpenArchivedMsiAsync(blobPath, cancellationToken);
|
||||
using var spool = CreateSpool();
|
||||
string sha256;
|
||||
long totalBytes;
|
||||
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
|
||||
{
|
||||
await hashing.CopyToAsync(spool, cancellationToken);
|
||||
sha256 = hashing.Sha256Hex;
|
||||
totalBytes = hashing.TotalBytes;
|
||||
}
|
||||
spool.Position = 0;
|
||||
return (sha256, totalBytes, ReadProductVersion(spool));
|
||||
}
|
||||
|
||||
private async Task WriteProvenanceAsync(
|
||||
ImeMsiArchiveEnvelope envelope, string version, string sourceUrl,
|
||||
ImeMsiArchiveEnvelope envelope, string version, string? sourceUrl,
|
||||
string sha256, long totalBytes, string? productVersion,
|
||||
IReadOnlyList<CandidateAttempt> attempts, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -429,7 +476,8 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
version,
|
||||
productVersion,
|
||||
url = sourceUrl,
|
||||
urlFromEvent = string.Equals(sourceUrl, envelope.MsiDownloadUrl, StringComparison.OrdinalIgnoreCase),
|
||||
urlFromEvent = sourceUrl is not null
|
||||
&& string.Equals(sourceUrl, envelope.MsiDownloadUrl, StringComparison.OrdinalIgnoreCase),
|
||||
msiMatchedBy = envelope.MsiMatchedBy,
|
||||
sha256,
|
||||
msiBytes = totalBytes,
|
||||
@@ -497,8 +545,8 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>True when the provenance sidecar already exists in the archive container.</summary>
|
||||
protected virtual async Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
/// <summary>True when the blob (installer or provenance sidecar) already exists in the archive container.</summary>
|
||||
protected virtual async Task<bool> ArchiveBlobExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
|
||||
return (await containerClient.GetBlobClient(blobPath).ExistsAsync(cancellationToken)).Value;
|
||||
|
||||
@@ -815,6 +815,23 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
internal static bool IsAutoActionEligible(SessionStatus status) => status == SessionStatus.InProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Pure gate: would the STORED counter trigger a warn or an auto-action that has not
|
||||
/// fired yet? Only then is the authoritative recount worth its Events-partition scan.
|
||||
/// The auto tier counts only while the session is still eligible — otherwise a finished
|
||||
/// session above the auto threshold would be rescanned on every sweep until retention.
|
||||
/// </summary>
|
||||
internal static bool HasPendingExcessiveEventAction(
|
||||
int storedEventCount, SessionStatus status, bool alreadyAlerted, bool alreadyAutoActioned,
|
||||
int warnThreshold, string? autoMode, int autoThreshold)
|
||||
{
|
||||
var warnPending = warnThreshold > 0 && storedEventCount > warnThreshold && !alreadyAlerted;
|
||||
var autoPending = !alreadyAutoActioned
|
||||
&& IsAutoActionEligible(status)
|
||||
&& DecideAutoAction(storedEventCount, autoMode, autoThreshold) != null;
|
||||
return warnPending || autoPending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every tenant for sessions whose EventCount exceeds the configured warn
|
||||
/// threshold (<see cref="AdminConfiguration.ExcessiveEventCountThreshold"/>) or the
|
||||
@@ -880,8 +897,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try
|
||||
{
|
||||
var runaways = await _sessionRepo.GetSessionsWithEventCountAboveAsync(tenantId, queryThreshold);
|
||||
foreach (var session in runaways)
|
||||
foreach (var candidate in runaways)
|
||||
{
|
||||
if (!HasPendingExcessiveEventAction(
|
||||
candidate.EventCount, candidate.Status,
|
||||
candidate.ExcessiveEventsAlerted, candidate.ExcessiveEventsAutoActioned,
|
||||
warnEnabled ? warnThreshold : 0, autoEnabled ? autoMode : "Off", autoThreshold))
|
||||
continue;
|
||||
|
||||
// The stored EventCount is an at-least-once increment: replayed batches
|
||||
// dedupe as rows but double the counter, and nothing corrects it while
|
||||
// the session is still running. Warn and block on the recounted rows only.
|
||||
var session = await RecountRunawayCandidateAsync(tenantId, candidate);
|
||||
if (session == null) continue;
|
||||
|
||||
// Warn-tier: emit once per session, regardless of auto-action state.
|
||||
if (warnEnabled && session.EventCount > warnThreshold && !session.ExcessiveEventsAlerted)
|
||||
{
|
||||
@@ -961,6 +990,32 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the stored counters of a runaway candidate with the authoritative Events row
|
||||
/// count and returns the re-read session. Returns <c>null</c> when the count could not be
|
||||
/// verified — the candidate is then left alone until the next sweep rather than warned
|
||||
/// about or blocked on an unverified number.
|
||||
/// </summary>
|
||||
private async Task<SessionSummary?> RecountRunawayCandidateAsync(string tenantId, SessionSummary candidate)
|
||||
{
|
||||
var scan = await _sessionRepo.ReconcileSessionCountersAsync(tenantId, candidate.SessionId);
|
||||
var recounted = scan == null ? null : await _sessionRepo.GetSessionAsync(tenantId, candidate.SessionId);
|
||||
if (recounted == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Excessive-event scan: could not verify the event count of session {SessionId} (tenant {TenantId}, stored {StoredCount}); skipped until the next sweep",
|
||||
candidate.SessionId, tenantId, candidate.EventCount);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recounted.EventCount != candidate.EventCount)
|
||||
_logger.LogWarning(
|
||||
"Excessive-event scan: session {SessionId} (tenant {TenantId}) stored EventCount {StoredCount} corrected to {ActualCount} rows",
|
||||
candidate.SessionId, tenantId, candidate.EventCount, recounted.EventCount);
|
||||
|
||||
return recounted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
|
||||
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user