Compare commits

...

14 Commits

Author SHA1 Message Date
Oliver Kieselbach 59ab47de8c chore: bump internal (D-266 network link changes are not activity)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:48:51 +02:00
Oliver Kieselbach f252a0709f fix(agent): network link changes no longer count as enrollment activity
network_state_change and network_connectivity_check join the non-activity
list next to power_state_change. A flapping dock or access point kept the
idle-stopped periodic collectors running for the whole episode and reset
the stall clock of the enrollment it was stalling. The events are emitted
unchanged; the device-info network snapshots stay activity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:48:50 +02:00
Oliver Kieselbach d6d9638981 chore: bump internal (D-265 recurring health-script runs, backlog B-c6h)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:38:55 +02:00
Oliver Kieselbach 020248fbda feat(agent): collapse recurring health-script runs into one summary per policy
IME re-runs remediation policies on its own schedule for as long as the
device is up, so an enrollment that waits for its user reported every run
of every policy. The tracker now emits a policy's first run and every run
whose result differs from the last reported one (compliance, exit code,
stderr, remediation status, error code); repeats are counted and surface
as one script_recurrence_summary per policy when the tracker stops.

The start signal of a known policy is held until its result is known,
because the running indicators pair a start with a later result; a start
without any result is still emitted after a grace. The counters live in
the tracker state file next to the file positions, so a hard kill rolls
both back together. Platform scripts are untouched. The summary does not
count as enrollment activity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:38:00 +02:00
Oliver Kieselbach a06a0dbfff chore: bump internal (D-264 emergency break stays a start-time check, backlog B-drn)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:08:22 +02:00
Oliver Kieselbach 96c24e0688 chore: bump internal (D-263 excessive-events recount, backlog B-7wp)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:46:03 +02:00
Oliver Kieselbach c156350183 fix(backend): excessive-events sweep warns and blocks on recounted rows
The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:45:11 +02:00
Oliver Kieselbach 15b25ff509 chore: bump internal (D-261 typed wrapper bodies, D-262 req.Query, backlog B-t5z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:31:15 +02:00
Oliver Kieselbach 5163d7d6a0 refactor(backend): read the request query through req.Query only
73 sites in 68 files built the query collection with
HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed
both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET
Core request type does not override it, so the change is behaviour-neutral.

- QueryParamGuardTests: ParseQueryString in the Functions project is limited to a
  named baseline of three sites that parse something other than the request query.
- A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded
  values, caching) so a worker update that changes the implementation fails a test.
- Test mocks that set Url answer Query through CallBase; a loose mock returns null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:28:11 +02:00
Oliver Kieselbach 9fd343d328 fix(backend): typed bodies and error envelope for backup, session-restore and delegation wrappers
Six functions wrote their responses through a private wrapper taking the body as
object, which no typed-response guard could see. Their error bodies carried the code
in the error slot, no correlation id, and RestoreRow returned the raw exception text
on a 500.

- Backup job status, row restore and backup trigger answer through ResponseHelper:
  errors are the envelope, the 500 is sanitized, the 202 body is BackupTriggerResponse.
- Same for the session-deletion maintenance trigger, RestoreSession's three error
  sites and the delegation acknowledgements (MessageResponse).
- Backup codes are constants (Constants.BackupErrorCodes) and part of apiErrorCodes;
  RunAlreadyActive and EnqueueFailed join ApiErrorCodes.
- TypedResponseGuardTests gains guard F: no HttpResponseData-returning method with an
  object parameter. BackupWireParityTests pins that the move from the backup
  serializer options to the worker pipeline leaves the wire unchanged.
- Web reads BackupTriggerResponse from the generated wire types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:19:38 +02:00
Oliver Kieselbach aef88f3337 chore: bump internal (IME archiver pre-walk check doc, backlog B-ryg)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:59:13 +02:00
Oliver Kieselbach 12af1d6c76 fix(backend): IME archiver accepts an existing archive entry before walking the hosts
A version whose installer was already in the archive could only turn
Archived through the 409 on the write-once upload, which needs a
distribution host that still serves the build. For a version no host
serves any more the row stayed Failed:VersionMismatch next to its own
blob and re-walked every host once a day.

The archiver now checks the archive first: an entry whose ProductVersion
is the observed version ends the job as Archived with the hash and size
of the archived bytes and no download; a missing provenance sidecar is
written without a source URL. An entry holding another build, or an
unreadable one, is not accepted and the walk runs as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:58:49 +02:00
Oliver Kieselbach 5433bff3db chore: bump internal (navbar profile photo plan and backlog)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 17:40:00 +02:00
Oliver Kieselbach 355509aa7c feat(web): show the signed-in user's profile photo in the navbar avatar
The browser loads the photo straight from Microsoft Graph with the User.Read
scope already consented at sign-in; it never reaches the backend. Any failure
(no photo, token, network) falls back to the initials. One request per account
and tab, cached in sessionStorage including the no-photo answer.

The four copied avatar blocks in the navbar become one UserAvatar component.
Graph host added to the URL registry and to connect-src in both CSPs; the
privacy page states where the photo comes from and that it is not stored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 17:39:41 +02:00
128 changed files with 1938 additions and 349 deletions
+1
View File
@@ -300,6 +300,7 @@
"script_completed",
"script_failed",
"script_timeout_suspected",
"script_recurrence_summary",
"esp_phase_changed",
"ime_agent_version",
"ime_token_failure",
@@ -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);
}
}
}
@@ -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.
@@ -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)
@@ -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)
@@ -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.
@@ -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; }
}
}
@@ -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));
}
}
@@ -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
@@ -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))
{
@@ -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);
@@ -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,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,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,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,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);
@@ -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))
{
@@ -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))
@@ -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);
}
}
}
@@ -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;
}
}
}
@@ -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))
@@ -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)
{
@@ -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,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");
@@ -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,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,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))
{
@@ -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))
{
@@ -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);
@@ -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!;
@@ -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);
@@ -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,
@@ -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";
@@ -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);
@@ -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);
@@ -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"];
@@ -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)
@@ -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";
@@ -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))
@@ -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!;
@@ -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))
{
@@ -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,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,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,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,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
@@ -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")
{
@@ -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();
@@ -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,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,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)
@@ -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;
@@ -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)
@@ -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,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,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,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))
@@ -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,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,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,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);
@@ -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);
}
@@ -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);
}
@@ -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