mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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>
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+9
-16
@@ -41,7 +41,7 @@ public class DelegationSelfServiceFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/slots")] HttpRequestData req)
|
||||
{
|
||||
var usage = await _slots.GetUsageAsync(req.GetRequestContext().TenantId);
|
||||
return await OkAsync(req, DelegatedSlotManagementFunction.ToResponse(usage));
|
||||
return await req.OkAsync(DelegatedSlotManagementFunction.ToResponse(usage));
|
||||
}
|
||||
|
||||
/// <summary>GET /api/delegations/managed — the tenants the caller's tenant manages.</summary>
|
||||
@@ -66,7 +66,7 @@ public class DelegationSelfServiceFunction
|
||||
});
|
||||
}
|
||||
|
||||
return await OkAsync(req, new ManagedTenantListResponse
|
||||
return await req.OkAsync(new ManagedTenantListResponse
|
||||
{
|
||||
HomeTenantId = home,
|
||||
Slots = DelegatedSlotManagementFunction.ToResponse(view.Slots),
|
||||
@@ -88,7 +88,7 @@ public class DelegationSelfServiceFunction
|
||||
return await BadAsync(req, "a valid tenantId (GUID) is required");
|
||||
|
||||
var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Delegation ended" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Delegation ended" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Invitations ──────────────────────────────────────────────────────────────
|
||||
@@ -118,7 +118,7 @@ public class DelegationSelfServiceFunction
|
||||
HoldUntilUtc = r.HoldUntilUtc,
|
||||
});
|
||||
}
|
||||
return await OkAsync(req, new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items });
|
||||
return await req.OkAsync(new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items });
|
||||
}
|
||||
|
||||
/// <summary>POST /api/delegations/invitations — mint a single-use invitation link (token shown once). TenantAdminOrGA.</summary>
|
||||
@@ -147,7 +147,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var result = await _svc.CancelInvitationAsync(ctx.TenantId, invitationId, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Assignees (the managing tenant's own users) ──────────────────────────────
|
||||
@@ -160,7 +160,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var home = req.GetRequestContext().TenantId;
|
||||
var assignees = await _svc.ListAssigneesAsync(home);
|
||||
return await OkAsync(req, new DelegationAssigneeListResponse
|
||||
return await req.OkAsync(new DelegationAssigneeListResponse
|
||||
{
|
||||
HomeTenantId = home,
|
||||
GroupId = Constants.TenantGroupIds.ForHomeTenant(home),
|
||||
@@ -200,7 +200,7 @@ public class DelegationSelfServiceFunction
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var result = await _svc.UnassignAsync(ctx.TenantId, upn, ctx.UserPrincipalName);
|
||||
return result.Ok ? await OkAsync(req, new { message = "Unassigned" }) : await FailAsync(req, result.Failure!);
|
||||
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Unassigned" }) : await FailAsync(req, result.Failure!);
|
||||
}
|
||||
|
||||
// ── Accept (the customer's tenant admin) ─────────────────────────────────────
|
||||
@@ -217,7 +217,7 @@ public class DelegationSelfServiceFunction
|
||||
if (!result.Ok)
|
||||
return await FailAsync(req, result.Failure!);
|
||||
var p = result.Value!;
|
||||
return await OkAsync(req, new DelegationAcceptPreviewResponse
|
||||
return await req.OkAsync(new DelegationAcceptPreviewResponse
|
||||
{
|
||||
HomeTenantId = p.HomeTenantId,
|
||||
HomeTenantDomain = p.HomeTenantDomain,
|
||||
@@ -245,7 +245,7 @@ public class DelegationSelfServiceFunction
|
||||
if (!result.Ok)
|
||||
return await FailAsync(req, result.Failure!);
|
||||
var o = result.Value!;
|
||||
return await OkAsync(req, new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId });
|
||||
return await req.OkAsync(new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId });
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -257,13 +257,6 @@ public class DelegationSelfServiceFunction
|
||||
return await req.ErrorAsync((HttpStatusCode)failure.Status, failure.Code, failure.Message);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> OkAsync(HttpRequestData req, object body)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadAsync(HttpRequestData req, string error)
|
||||
{
|
||||
return await req.BadRequestAsync(error);
|
||||
|
||||
+4
-13
@@ -1,7 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -36,18 +36,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
var (job, _) = await _jobs.GetWithETagAsync(jobId, req.FunctionContext.CancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new { error = "JobNotFound", jobId });
|
||||
return await req.NotFoundAsync($"Backup job {jobId} not found.", Constants.BackupErrorCodes.JobNotFound);
|
||||
}
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, job);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json);
|
||||
return response;
|
||||
return await req.OkAsync(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Backup;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
}
|
||||
catch (BackupTerminalException ex)
|
||||
{
|
||||
return await WriteErrorAsync(req, HttpStatusCode.BadRequest, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
return await req.BadRequestAsync(ex.Message, ex.Code).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 3. Dispatch on mode
|
||||
@@ -71,7 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
{
|
||||
var preview = await _restoreService.PreviewRowAsync(
|
||||
backupId, body.TableName, body.PartitionKey, body.RowKey, ct).ConfigureAwait(false);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, preview).ConfigureAwait(false);
|
||||
return await req.OkAsync(preview).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -92,13 +92,13 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
_logger.LogWarning(evtEx, "RestoreRow: ops event recording failed — write itself succeeded");
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.OK, commit).ConfigureAwait(false);
|
||||
return await req.OkAsync(commit).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (BackupTerminalException ex)
|
||||
{
|
||||
var status = MapErrorCodeToStatus(ex.Code);
|
||||
return await WriteErrorAsync(req, status, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
return await req.ErrorAsync(status, ex.Code, ex.Message).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -107,39 +107,26 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RestoreRow: unexpected failure for backupId={BackupId} table={Table}", backupId, body.TableName);
|
||||
return await WriteErrorAsync(req, HttpStatusCode.InternalServerError, "InternalError", ex.Message).ConfigureAwait(false);
|
||||
// Sanitized envelope: the raw exception text stays in the log, the correlation id is the handle.
|
||||
return await req.InternalServerErrorAsync(
|
||||
_logger, ex, $"RestoreRow backupId={backupId} table={body.TableName}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpStatusCode MapErrorCodeToStatus(string code) => code switch
|
||||
{
|
||||
"BackupNotFound" => HttpStatusCode.NotFound,
|
||||
"RowNotInBackup" => HttpStatusCode.NotFound,
|
||||
"TableNotInBackup" => HttpStatusCode.Conflict,
|
||||
"ManifestCorrupt" => HttpStatusCode.Conflict,
|
||||
"ManifestSchemaUnsupported" => HttpStatusCode.Conflict,
|
||||
"IntegrityCheckFailed" => HttpStatusCode.Conflict,
|
||||
"BlobChangedSinceValidation" => HttpStatusCode.Conflict,
|
||||
"RowChangedSinceValidation" => HttpStatusCode.Conflict,
|
||||
"CurrentRowChanged" => HttpStatusCode.Conflict,
|
||||
"MaintenanceInProgress" => HttpStatusCode.Conflict,
|
||||
"MaintenanceLeaseLost" => HttpStatusCode.Conflict,
|
||||
_ => HttpStatusCode.BadRequest,
|
||||
Constants.BackupErrorCodes.BackupNotFound => HttpStatusCode.NotFound,
|
||||
Constants.BackupErrorCodes.RowNotInBackup => HttpStatusCode.NotFound,
|
||||
Constants.BackupErrorCodes.TableNotInBackup => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.ManifestCorrupt => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.ManifestSchemaUnsupported => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.IntegrityCheckFailed => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.RowChangedSinceValidation => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.CurrentRowChanged => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.MaintenanceInProgress => HttpStatusCode.Conflict,
|
||||
Constants.BackupErrorCodes.MaintenanceLeaseLost => HttpStatusCode.Conflict,
|
||||
_ => HttpStatusCode.BadRequest,
|
||||
};
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json).ConfigureAwait(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteErrorAsync(HttpRequestData req, HttpStatusCode status, string code, string message)
|
||||
{
|
||||
return await WriteJsonAsync(req, status, new { error = code, message }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-24
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Backup.Queue;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -63,11 +63,9 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
if (!inserted)
|
||||
{
|
||||
// Guid-N collision (effectively impossible) — surface so the operator can retry.
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
error = "JobIdCollision",
|
||||
message = "Failed to allocate a unique jobId — please retry.",
|
||||
});
|
||||
return await req.ConflictAsync(
|
||||
"Failed to allocate a unique jobId — please retry.",
|
||||
Constants.BackupErrorCodes.JobIdCollision);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -91,28 +89,17 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
await _jobs.TryUpdateWithCasAsync(fresh, etag.Value, req.FunctionContext.CancellationToken);
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
error = "EnqueueFailed",
|
||||
message = "Failed to enqueue backup job — job status has been marked Failed.",
|
||||
jobId,
|
||||
});
|
||||
return await req.ErrorAsync(
|
||||
HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.EnqueueFailed,
|
||||
$"Failed to enqueue backup job {jobId} — job status has been marked Failed.");
|
||||
}
|
||||
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Accepted, new
|
||||
return await req.JsonAsync(HttpStatusCode.Accepted, new BackupTriggerResponse
|
||||
{
|
||||
jobId,
|
||||
statusUrl = $"/api/global/backups/jobs/{jobId}",
|
||||
JobId = jobId,
|
||||
StatusUrl = $"/api/global/backups/jobs/{jobId}",
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-22
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Backup;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -65,11 +66,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
}
|
||||
catch (LeaseHeldException)
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
error = "RunAlreadyActive",
|
||||
message = "A session-deletion maintenance run is already active — check the Session Cleanup page for progress.",
|
||||
});
|
||||
return await req.ConflictAsync(
|
||||
"A session-deletion maintenance run is already active — check the Session Cleanup page for progress.",
|
||||
Constants.ApiErrorCodes.RunAlreadyActive);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -79,27 +78,18 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "TriggerSessionDeletionMaintenance: enqueue failed (triggeredBy={TriggeredBy})", actor);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
error = "EnqueueFailed",
|
||||
message = "Failed to enqueue the maintenance run — please retry.",
|
||||
});
|
||||
return await req.ErrorAsync(
|
||||
HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.EnqueueFailed,
|
||||
"Failed to enqueue the maintenance run — please retry.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("TriggerSessionDeletionMaintenance: run queued (triggeredBy={TriggeredBy})", actor);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.Accepted, new
|
||||
return await req.JsonAsync(HttpStatusCode.Accepted, new SessionDeletionMaintenanceTriggerResponse
|
||||
{
|
||||
message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.",
|
||||
triggeredBy = actor,
|
||||
Message = "Maintenance run queued — progress surfaces as SessionDeletionMaintenance* ops events.",
|
||||
TriggeredBy = actor,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
await response.WriteStringAsync(JsonSerializer.Serialize(body));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-21
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
@@ -51,7 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.BadRequest, new { success = false, message = "sessionId is required" });
|
||||
return await req.BadRequestAsync("sessionId is required");
|
||||
}
|
||||
|
||||
var read = await req.ReadAsync<RestoreSessionRequest>();
|
||||
@@ -95,12 +94,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// the tenantId in the request body (or via the X-Target-Tenant header).
|
||||
if (string.IsNullOrWhiteSpace(body.TenantId))
|
||||
{
|
||||
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new
|
||||
{
|
||||
success = false,
|
||||
message = $"Session {sessionId} not found in any active SessionsIndex entry. " +
|
||||
"If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body."
|
||||
});
|
||||
return await req.NotFoundAsync(
|
||||
$"Session {sessionId} not found in any active SessionsIndex entry.",
|
||||
hint: "If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body.");
|
||||
}
|
||||
tenantId = body.TenantId!;
|
||||
}
|
||||
@@ -133,11 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"RestoreSession: unhandled exception for tenant={TenantId} session={SessionId} manifestId={ManifestId}",
|
||||
tenantId, sessionId, body.ManifestId);
|
||||
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError,
|
||||
new { success = false, message = "Internal error during restore — see audit log + telemetry for details.", exceptionType = ex.GetType().Name });
|
||||
return await req.InternalServerErrorAsync(
|
||||
_logger, ex, $"RestoreSession tenant={tenantId} session={sessionId} manifestId={body.ManifestId}");
|
||||
}
|
||||
|
||||
return await WriteResultAsync(req, result);
|
||||
@@ -175,14 +168,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
DurationMs = result.DurationMs,
|
||||
};
|
||||
|
||||
return await WriteJsonAsync(req, status, body);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
// One shape for every outcome (see SessionRestoreResponse): the status carries the verdict.
|
||||
return await req.JsonAsync(status, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Azure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -58,32 +59,32 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
/// </summary>
|
||||
public async Task<ValidationResult> ValidateAsync(string backupId, string tableName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException("InvalidBackupId", "backupId is required");
|
||||
if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException("InvalidTable", "tableName is required");
|
||||
if (string.IsNullOrEmpty(backupId)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId is required");
|
||||
if (string.IsNullOrEmpty(tableName)) throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required");
|
||||
|
||||
// 1. Manifest exists + parseable
|
||||
var (payload, _) = await _store.ReadManifestAsync(backupId, ct).ConfigureAwait(false);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BackupTerminalException("BackupNotFound", $"manifest for backupId '{backupId}' was not found");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.BackupNotFound, $"manifest for backupId '{backupId}' was not found");
|
||||
}
|
||||
|
||||
CriticalTableBackupManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<CriticalTableBackupManifest>(payload, BackupManifestJson.SerializerOptions)
|
||||
?? throw new BackupTerminalException("ManifestCorrupt", "manifest deserialised to null");
|
||||
?? throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, "manifest deserialised to null");
|
||||
}
|
||||
catch (BackupTerminalException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BackupTerminalException("ManifestCorrupt", $"manifest JSON parse failed: {ex.Message}", ex);
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.ManifestCorrupt, $"manifest JSON parse failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (manifest.SchemaVersion != SupportedSchemaVersion)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestSchemaUnsupported",
|
||||
Constants.BackupErrorCodes.ManifestSchemaUnsupported,
|
||||
$"manifest schemaVersion={manifest.SchemaVersion} not supported by this restore endpoint (expected {SupportedSchemaVersion})");
|
||||
}
|
||||
|
||||
@@ -92,13 +93,13 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (entry == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"TableNotInBackup",
|
||||
Constants.BackupErrorCodes.TableNotInBackup,
|
||||
$"no entry for tableName '{tableName}' in manifest {backupId}");
|
||||
}
|
||||
if (entry.Status != TableBackupStatus.Ok && entry.Status != TableBackupStatus.Empty)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"TableNotInBackup",
|
||||
Constants.BackupErrorCodes.TableNotInBackup,
|
||||
$"manifest entry for '{tableName}' has Status={entry.Status} — restore is only allowed from Ok or Empty entries");
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (!string.Equals(entry.BlobName, expectedBlobName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestCorrupt",
|
||||
Constants.BackupErrorCodes.ManifestCorrupt,
|
||||
$"manifest entry BlobName='{entry.BlobName}' does not match canonical '{expectedBlobName}'");
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (openResult == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BackupNotFound",
|
||||
Constants.BackupErrorCodes.BackupNotFound,
|
||||
$"NDJSON blob '{expectedBlobName}' is missing despite manifest entry present — orphan manifest");
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// The IfMatch precondition can also fire mid-stream if the blob was
|
||||
// overwritten between OpenRead and the final byte. Same recovery as
|
||||
// OpenNdjsonReadAsync's 412 path.
|
||||
throw new BackupTerminalException("BlobChangedSinceValidation", "NDJSON blob changed during SHA verification", ex);
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.BlobChangedSinceValidation, "NDJSON blob changed during SHA verification", ex);
|
||||
}
|
||||
|
||||
var expectedSha = (entry.Sha256Hex ?? string.Empty).ToLowerInvariant();
|
||||
@@ -146,7 +147,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
"BackupRestoreInputValidator: SHA mismatch for backupId={BackupId} table={Table} (expected={Expected}, actual={Actual}) — refusing restore",
|
||||
backupId, tableName, expectedSha, actualSha);
|
||||
throw new BackupTerminalException(
|
||||
"IntegrityCheckFailed",
|
||||
Constants.BackupErrorCodes.IntegrityCheckFailed,
|
||||
$"SHA-256 mismatch for table '{tableName}' (expected {expectedSha}, got {actualSha}) — backup may be tampered or partially written");
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BlobChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation,
|
||||
$"NDJSON blob '{BuildNdjsonBlobName(backupId, tableName)}' changed between GetProperties and OpenRead — refusing to read a half-version",
|
||||
ex);
|
||||
}
|
||||
|
||||
+10
-10
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (dump == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowNotInBackup",
|
||||
Constants.BackupErrorCodes.RowNotInBackup,
|
||||
$"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}");
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (LeaseHeldException ex)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MaintenanceInProgress",
|
||||
Constants.BackupErrorCodes.MaintenanceInProgress,
|
||||
"another maintenance operation is in progress — please retry shortly",
|
||||
ex);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (dump == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowNotInBackup",
|
||||
Constants.BackupErrorCodes.RowNotInBackup,
|
||||
$"row (pk='{partitionKey}', rk='{rowKey}') was not found in backup {backupId}/{tableName}");
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (!string.Equals(freshSha, (ifSha256 ?? string.Empty).ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"RowChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.RowChangedSinceValidation,
|
||||
$"backup row SHA-256 changed between preview and commit (preview echoed {ifSha256}, fresh {freshSha}) — re-open the preview to refresh");
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 404)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"CurrentRowChanged",
|
||||
Constants.BackupErrorCodes.CurrentRowChanged,
|
||||
$"live row was modified or deleted since preview (status={ex.Status}) — re-open the preview",
|
||||
ex);
|
||||
}
|
||||
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (RequestFailedException ex) when (StorageErrors.IsAlreadyExists(ex))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"CurrentRowChanged",
|
||||
Constants.BackupErrorCodes.CurrentRowChanged,
|
||||
"live row was created since preview — re-open the preview",
|
||||
ex);
|
||||
}
|
||||
@@ -266,7 +266,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// up to the HTTP layer's generic catch and renders as a 500.
|
||||
var reason = holder.RenewalFailureReason ?? "handler cancelled without explicit renewal failure";
|
||||
throw new BackupTerminalException(
|
||||
"MaintenanceLeaseLost",
|
||||
Constants.BackupErrorCodes.MaintenanceLeaseLost,
|
||||
$"maintenance lease lost during commit — retry shortly ({reason})",
|
||||
ex);
|
||||
}
|
||||
@@ -287,7 +287,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (open == null)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"BackupNotFound",
|
||||
Constants.BackupErrorCodes.BackupNotFound,
|
||||
$"NDJSON blob for {backupId}/{tableName}.ndjson disappeared between validation and scan");
|
||||
}
|
||||
if (!open.Value.ETag.Equals(expectedETag))
|
||||
@@ -296,7 +296,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// validator and scan, the blob was overwritten.
|
||||
try { open.Value.Stream.Dispose(); } catch { /* best effort */ }
|
||||
throw new BackupTerminalException(
|
||||
"BlobChangedSinceValidation",
|
||||
Constants.BackupErrorCodes.BlobChangedSinceValidation,
|
||||
"NDJSON blob ETag changed between validation hash and scan — refusing to read mismatching bytes");
|
||||
}
|
||||
return open.Value;
|
||||
@@ -331,7 +331,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"ManifestCorrupt",
|
||||
Constants.BackupErrorCodes.ManifestCorrupt,
|
||||
$"NDJSON line failed to parse as DeletionRowDump: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
+9
-9
@@ -28,21 +28,21 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupId))
|
||||
{
|
||||
throw new BackupTerminalException("InvalidBackupId", "backupId route segment is empty");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidBackupId, "backupId route segment is empty");
|
||||
}
|
||||
if (request == null)
|
||||
{
|
||||
throw new BackupTerminalException("MissingBody", "request body is required");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.MissingBody, "request body is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.TableName))
|
||||
{
|
||||
throw new BackupTerminalException("InvalidTable", "tableName is required");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidTable, "tableName is required");
|
||||
}
|
||||
if (!Constants.CriticalBackupTables.All.Contains(request.TableName, StringComparer.Ordinal))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"InvalidTable",
|
||||
Constants.BackupErrorCodes.InvalidTable,
|
||||
$"tableName '{request.TableName}' is not in the critical-backup catalog — only the 15 critical tables are restorable");
|
||||
}
|
||||
|
||||
@@ -51,16 +51,16 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
// is not found for the given keys.
|
||||
if (request.PartitionKey == null)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidKeys", "partitionKey is required (may be empty string but not null)");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "partitionKey is required (may be empty string but not null)");
|
||||
}
|
||||
if (request.RowKey == null)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidKeys", "rowKey is required (may be empty string but not null)");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidKeys, "rowKey is required (may be empty string but not null)");
|
||||
}
|
||||
|
||||
if (request.Mode != RestoreRowMode.Preview && request.Mode != RestoreRowMode.Commit)
|
||||
{
|
||||
throw new BackupTerminalException("InvalidMode", $"mode must be 'preview' or 'commit', got '{request.Mode}'");
|
||||
throw new BackupTerminalException(Constants.BackupErrorCodes.InvalidMode, $"mode must be 'preview' or 'commit', got '{request.Mode}'");
|
||||
}
|
||||
|
||||
if (request.Mode == RestoreRowMode.Commit)
|
||||
@@ -68,13 +68,13 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
if (string.IsNullOrEmpty(request.IfSha256))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MissingPrecondition",
|
||||
Constants.BackupErrorCodes.MissingPrecondition,
|
||||
"ifSha256 is required on commit — echo the rowSha256 from the preview response");
|
||||
}
|
||||
if (!IsLowerHex64(request.IfSha256))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"MissingPrecondition",
|
||||
Constants.BackupErrorCodes.MissingPrecondition,
|
||||
"ifSha256 must be 64 lowercase hex characters (SHA-256 hex digest)");
|
||||
}
|
||||
// ifCurrentETag is intentionally nullable: null carries the precondition
|
||||
|
||||
@@ -912,6 +912,12 @@ export interface BackupJobStatus {
|
||||
/** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */
|
||||
export type BackupOutcome = "Success" | "Partial";
|
||||
|
||||
/** Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus. */
|
||||
export interface BackupTriggerResponse {
|
||||
jobId: string;
|
||||
statusUrl: string;
|
||||
}
|
||||
|
||||
/** Body of POST devices/block (Global Admin). */
|
||||
export interface BlockDeviceRequest {
|
||||
tenantId: string;
|
||||
@@ -3934,6 +3940,12 @@ export interface SessionDeletionListItem {
|
||||
ageMinutes: number;
|
||||
}
|
||||
|
||||
/** Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events. */
|
||||
export interface SessionDeletionMaintenanceTriggerResponse {
|
||||
message: string;
|
||||
triggeredBy: string;
|
||||
}
|
||||
|
||||
/** Worker progress projection nested in GetSessionDeletionManifestResponse. */
|
||||
export interface SessionDeletionProgressWire {
|
||||
snapshotSha256: string;
|
||||
|
||||
@@ -226,6 +226,8 @@ export const API_ERROR_CODES = [
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"RunAlreadyActive",
|
||||
"EnqueueFailed",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
@@ -240,5 +242,24 @@ export const API_ERROR_CODES = [
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached",
|
||||
"InvalidBackupId",
|
||||
"MissingBody",
|
||||
"InvalidTable",
|
||||
"InvalidKeys",
|
||||
"InvalidMode",
|
||||
"MissingPrecondition",
|
||||
"BackupNotFound",
|
||||
"RowNotInBackup",
|
||||
"JobNotFound",
|
||||
"TableNotInBackup",
|
||||
"ManifestCorrupt",
|
||||
"ManifestSchemaUnsupported",
|
||||
"IntegrityCheckFailed",
|
||||
"BlobChangedSinceValidation",
|
||||
"RowChangedSinceValidation",
|
||||
"CurrentRowChanged",
|
||||
"MaintenanceInProgress",
|
||||
"MaintenanceLeaseLost",
|
||||
"JobIdCollision",
|
||||
] as const;
|
||||
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||
|
||||
@@ -280,8 +280,8 @@ namespace AutopilotMonitor.Shared
|
||||
/// <summary>
|
||||
/// Machine-readable <c>code</c> values of the typed error envelope (<c>ApiErrorResponse</c>).
|
||||
/// Status defaults first (one per HTTP status the API emits), then the codes a consumer
|
||||
/// branches on. Domain code classes (<see cref="DelegationCodes"/>, <see cref="DelegatedSlots"/>)
|
||||
/// keep their own values; the manifest exports all of them together as <c>apiErrorCodes</c>.
|
||||
/// branches on. Domain code classes (<see cref="DelegationCodes"/>, <see cref="DelegatedSlots"/>,
|
||||
/// <see cref="BackupErrorCodes"/>) keep their own values; the manifest exports all of them together as <c>apiErrorCodes</c>.
|
||||
/// Wire contract — keep stable, PascalCase.
|
||||
/// </summary>
|
||||
public static class ApiErrorCodes
|
||||
@@ -335,6 +335,45 @@ namespace AutopilotMonitor.Shared
|
||||
public const string CascadePoisonedUseRestore = "CascadePoisonedUseRestore";
|
||||
public const string KillSwitchActive = "KillSwitchActive";
|
||||
public const string CasExhaustedRetryLater = "CasExhaustedRetryLater";
|
||||
|
||||
// ── queued operator runs (backup trigger, session-deletion maintenance trigger) ──
|
||||
/// <summary>409: a run of the same kind already holds the lease.</summary>
|
||||
public const string RunAlreadyActive = "RunAlreadyActive";
|
||||
/// <summary>500: the queue refused the message; nothing was scheduled.</summary>
|
||||
public const string EnqueueFailed = "EnqueueFailed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable <c>code</c> values of the critical-table backup and restore surface
|
||||
/// (<c>global/backups/*</c>). <c>BackupTerminalException.Code</c> carries one of these;
|
||||
/// <c>RestoreRowFunction</c> maps it to the HTTP status. Wire contract — keep stable.
|
||||
/// </summary>
|
||||
public static class BackupErrorCodes
|
||||
{
|
||||
// ── request validation (400) ──
|
||||
public const string InvalidBackupId = "InvalidBackupId";
|
||||
public const string MissingBody = "MissingBody";
|
||||
public const string InvalidTable = "InvalidTable";
|
||||
public const string InvalidKeys = "InvalidKeys";
|
||||
public const string InvalidMode = "InvalidMode";
|
||||
public const string MissingPrecondition = "MissingPrecondition";
|
||||
|
||||
// ── not found (404) ──
|
||||
public const string BackupNotFound = "BackupNotFound";
|
||||
public const string RowNotInBackup = "RowNotInBackup";
|
||||
public const string JobNotFound = "JobNotFound";
|
||||
|
||||
// ── state conflicts (409) ──
|
||||
public const string TableNotInBackup = "TableNotInBackup";
|
||||
public const string ManifestCorrupt = "ManifestCorrupt";
|
||||
public const string ManifestSchemaUnsupported = "ManifestSchemaUnsupported";
|
||||
public const string IntegrityCheckFailed = "IntegrityCheckFailed";
|
||||
public const string BlobChangedSinceValidation = "BlobChangedSinceValidation";
|
||||
public const string RowChangedSinceValidation = "RowChangedSinceValidation";
|
||||
public const string CurrentRowChanged = "CurrentRowChanged";
|
||||
public const string MaintenanceInProgress = "MaintenanceInProgress";
|
||||
public const string MaintenanceLeaseLost = "MaintenanceLeaseLost";
|
||||
public const string JobIdCollision = "JobIdCollision";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -161,6 +161,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool AggregateOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is
|
||||
/// queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class SessionDeletionMaintenanceTriggerResponse : IApiResponse
|
||||
{
|
||||
public string Message { get; set; } = default!;
|
||||
public string TriggeredBy { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc).
|
||||
/// </summary>
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Shared.Models.Backup
|
||||
/// risk after enum refactorings.
|
||||
/// </summary>
|
||||
[WireContract]
|
||||
public sealed class BackupJobStatus
|
||||
public sealed class BackupJobStatus : IApiResponse
|
||||
{
|
||||
public string JobId { get; set; } = string.Empty;
|
||||
public BackupJobKind Kind { get; set; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AutopilotMonitor.Shared.Models.Backup
|
||||
{
|
||||
/// <summary>
|
||||
/// Response of <c>POST global/backups/trigger</c> (202 Accepted): the job is queued, not
|
||||
/// done. The portal polls <see cref="StatusUrl"/> for the <see cref="BackupJobStatus"/>.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public sealed class BackupTriggerResponse : IApiResponse
|
||||
{
|
||||
public string JobId { get; set; } = string.Empty;
|
||||
public string StatusUrl { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Shared.Models.Backup
|
||||
/// live ETag (or null) to echo on commit.
|
||||
/// </summary>
|
||||
[WireContract]
|
||||
public sealed class RestoreRowPreviewResponse
|
||||
public sealed class RestoreRowPreviewResponse : IApiResponse
|
||||
{
|
||||
public string BackupId { get; set; } = string.Empty;
|
||||
public string TableName { get; set; } = string.Empty;
|
||||
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Shared.Models.Backup
|
||||
/// Successful response body of <c>mode=commit</c>. Echoes the write outcome.
|
||||
/// </summary>
|
||||
[WireContract]
|
||||
public sealed class RestoreRowCommitResponse
|
||||
public sealed class RestoreRowCommitResponse : IApiResponse
|
||||
{
|
||||
public string BackupId { get; set; } = string.Empty;
|
||||
public string TableName { get; set; } = string.Empty;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { backupUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api, type BackupTriggerResponse } from "@/lib/api";
|
||||
import type { BackupJobStatus, ListBackupsResponse } from "@/utils/wire-types.generated";
|
||||
import { api } from "@/lib/api";
|
||||
import type { BackupJobStatus, BackupTriggerResponse, ListBackupsResponse } from "@/utils/wire-types.generated";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { useAdminConfig } from "../AdminConfigContext";
|
||||
import { AdminNotifications } from "../AdminNotifications";
|
||||
|
||||
@@ -848,9 +848,4 @@ export interface BackupManifest {
|
||||
tables: BackupTableEntry[];
|
||||
}
|
||||
|
||||
export interface BackupTriggerResponse {
|
||||
jobId: string;
|
||||
statusUrl: string;
|
||||
}
|
||||
|
||||
// ── PR2: single-row restore — request/response types are generated (RestoreRowRequest, RestoreRowMode) ──
|
||||
|
||||
@@ -637,6 +637,8 @@ export const SHARED_MANIFEST = {
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"RunAlreadyActive",
|
||||
"EnqueueFailed",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
@@ -650,7 +652,26 @@ export const SHARED_MANIFEST = {
|
||||
"NotATenantMember",
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached"
|
||||
"DelegatedSlotLimitReached",
|
||||
"InvalidBackupId",
|
||||
"MissingBody",
|
||||
"InvalidTable",
|
||||
"InvalidKeys",
|
||||
"InvalidMode",
|
||||
"MissingPrecondition",
|
||||
"BackupNotFound",
|
||||
"RowNotInBackup",
|
||||
"JobNotFound",
|
||||
"TableNotInBackup",
|
||||
"ManifestCorrupt",
|
||||
"ManifestSchemaUnsupported",
|
||||
"IntegrityCheckFailed",
|
||||
"BlobChangedSinceValidation",
|
||||
"RowChangedSinceValidation",
|
||||
"CurrentRowChanged",
|
||||
"MaintenanceInProgress",
|
||||
"MaintenanceLeaseLost",
|
||||
"JobIdCollision"
|
||||
],
|
||||
"tenantRoles": [
|
||||
"Admin",
|
||||
|
||||
@@ -633,6 +633,8 @@
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"RunAlreadyActive",
|
||||
"EnqueueFailed",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
@@ -646,7 +648,26 @@
|
||||
"NotATenantMember",
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached"
|
||||
"DelegatedSlotLimitReached",
|
||||
"InvalidBackupId",
|
||||
"MissingBody",
|
||||
"InvalidTable",
|
||||
"InvalidKeys",
|
||||
"InvalidMode",
|
||||
"MissingPrecondition",
|
||||
"BackupNotFound",
|
||||
"RowNotInBackup",
|
||||
"JobNotFound",
|
||||
"TableNotInBackup",
|
||||
"ManifestCorrupt",
|
||||
"ManifestSchemaUnsupported",
|
||||
"IntegrityCheckFailed",
|
||||
"BlobChangedSinceValidation",
|
||||
"RowChangedSinceValidation",
|
||||
"CurrentRowChanged",
|
||||
"MaintenanceInProgress",
|
||||
"MaintenanceLeaseLost",
|
||||
"JobIdCollision"
|
||||
],
|
||||
"tenantRoles": [
|
||||
"Admin",
|
||||
@@ -4879,6 +4900,26 @@
|
||||
"Partial"
|
||||
]
|
||||
},
|
||||
"BackupTriggerResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "jobId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "statusUrl",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"BlockDeviceRequest": {
|
||||
"kind": "object",
|
||||
"doc": "Body of POST devices/block (Global Admin).",
|
||||
@@ -18179,6 +18220,26 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SessionDeletionMaintenanceTriggerResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "message",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "triggeredBy",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"SessionDeletionProgressWire": {
|
||||
"kind": "object",
|
||||
"doc": "Worker progress projection nested in GetSessionDeletionManifestResponse.",
|
||||
|
||||
@@ -912,6 +912,12 @@ export interface BackupJobStatus {
|
||||
/** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */
|
||||
export type BackupOutcome = "Success" | "Partial";
|
||||
|
||||
/** Response of POST global/backups/trigger (202 Accepted): the job is queued, not done. The portal polls StatusUrl for the BackupJobStatus. */
|
||||
export interface BackupTriggerResponse {
|
||||
jobId: string;
|
||||
statusUrl: string;
|
||||
}
|
||||
|
||||
/** Body of POST devices/block (Global Admin). */
|
||||
export interface BlockDeviceRequest {
|
||||
tenantId: string;
|
||||
@@ -3934,6 +3940,12 @@ export interface SessionDeletionListItem {
|
||||
ageMinutes: number;
|
||||
}
|
||||
|
||||
/** Response of POST global/session-deletions/maintenance/trigger (202 Accepted): the run is queued, not done. Its progress surfaces as SessionDeletionMaintenance* ops events. */
|
||||
export interface SessionDeletionMaintenanceTriggerResponse {
|
||||
message: string;
|
||||
triggeredBy: string;
|
||||
}
|
||||
|
||||
/** Worker progress projection nested in GetSessionDeletionManifestResponse. */
|
||||
export interface SessionDeletionProgressWire {
|
||||
snapshotSha256: string;
|
||||
|
||||
Reference in New Issue
Block a user