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:
Oliver Kieselbach
2026-09-21 22:19:38 +02:00
parent aef88f3337
commit 9fd343d328
25 changed files with 476 additions and 178 deletions
@@ -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
@@ -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,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;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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
@@ -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";
-5
View File
@@ -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;