using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
///
/// Admin-triggered session deletion. Always dispatches to the V2 cascade producer.
///
/// One HTTP gate runs before the producer: the global kill-switch (returns 503 uniformly
/// so no data-state info leaks while the operator override is active). The producer itself
/// owns the existence check (SessionMissing → 404), lock-state mapping
/// (AlreadyInFlight/Poisoned → 409), AND the intentional recovery resume
/// for stranded Queued / Preparing+Snapshot rows (→ 202). Routing those
/// through the producer is required so admin retries can recover crashed cascades —
/// blocking them at an HTTP gate strands the session until retention age.
///
///
public class DeleteSessionFunction
{
private readonly ILogger _logger;
private readonly AdminConfigurationService _adminConfig;
private readonly ISessionDeletionEnqueuer _enqueuer;
public DeleteSessionFunction(
ILogger logger,
AdminConfigurationService adminConfig,
ISessionDeletionEnqueuer enqueuer)
{
_logger = logger;
_adminConfig = adminConfig;
_enqueuer = enqueuer;
}
[Function("DeleteSession")]
public async Task Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "sessions/{sessionId}")] HttpRequestData req,
string sessionId)
{
_logger.LogInformation($"DeleteSession function processing request for session {sessionId}");
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// Tenant scoping is TenantScoping.QueryParam (catalog) so middleware validates the
// optional ?tenantId=... against the caller's role and writes the resolved tenant
// into RequestContext.TargetTenantId.
var ctx = req.GetRequestContext();
var tenantId = ctx.TargetTenantId;
var userIdentifier = ctx.UserPrincipalName;
_logger.LogInformation($"Deleting session {sessionId} for tenant {tenantId} by user {userIdentifier}");
// Gate — Kill-switch. Uncached, fail-closed read so a flip-ON is honored across
// scaled-out instances within seconds (not up to 5 min cache TTL). Runs before
// the producer so 503 wins over any data-state response and no info leaks.
var gateResult = EvaluateAdminDeleteGates(
killSwitchActive: await _adminConfig.IsSessionDeletionKillSwitchActiveAsync());
if (gateResult.HasValue)
{
_logger.LogWarning(
"DeleteSession rejected: SessionDeletionKillSwitch is active. tenant={TenantId} session={SessionId}",
tenantId, sessionId);
return await WriteResponse(req, gateResult.Value.Status, gateResult.Value.Body);
}
// Gate passed — hand off to the V2 cascade producer (handles 404, locked states,
// and the Queued/Preparing+Snapshot recovery resume paths).
return await RunV2CascadePathAsync(req, tenantId, sessionId, userIdentifier);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error deleting session {sessionId}");
return await WriteResponse(req, HttpStatusCode.InternalServerError, new
{
success = false,
message = "Internal server error",
});
}
}
///
/// Pure pre-dispatch gate evaluation. Returns the kill-switch 503 short-circuit, or
/// null to hand off to the producer.
///
/// Everything else — 404 SessionMissing, 409 AlreadyInFlight, 409 Poisoned, and the
/// intentional Queued / Preparing+Snapshot → 202 Resume recovery paths —
/// is owned by the producer. Adding an HTTP-level lock-state gate here would block the
/// producer's recovery and strand admin-triggered cascades that crashed mid-flight.
///
///
internal static (HttpStatusCode Status, object Body)? EvaluateAdminDeleteGates(bool killSwitchActive)
{
if (killSwitchActive)
{
return (HttpStatusCode.ServiceUnavailable, new
{
success = false,
message = "Session deletion is temporarily disabled by global kill-switch.",
hint = "kill_switch_active",
});
}
return null;
}
///
/// V2 cascade path — enqueue a manifest-driven cascade and let the worker drain it.
/// Returns 202 Accepted on success; translates the producer's enqueue outcomes to HTTP
/// statuses via .
///
private async Task RunV2CascadePathAsync(
HttpRequestData req, string tenantId, string sessionId, string userIdentifier)
{
var actor = new DeletionActor { Type = "admin", Actor = userIdentifier };
var result = await _enqueuer.EnqueueAsync(tenantId, sessionId, "admin_delete", actor);
var status = MapEnqueueOutcomeToStatus(result.Outcome);
var body = BuildV2ResponseBody(result, sessionId);
switch (result.Outcome)
{
case SessionDeletionEnqueueOutcome.Enqueued:
_logger.LogInformation(
"V2 cascade enqueued. tenant={TenantId} session={SessionId} manifestId={ManifestId}",
tenantId, sessionId, result.ManifestId);
break;
case SessionDeletionEnqueueOutcome.CasExhausted:
_logger.LogWarning(
"V2 cascade enqueue exhausted CAS retries. tenant={TenantId} session={SessionId}",
tenantId, sessionId);
break;
case SessionDeletionEnqueueOutcome.AlreadyInFlight:
case SessionDeletionEnqueueOutcome.Poisoned:
case SessionDeletionEnqueueOutcome.KillSwitchActive:
case SessionDeletionEnqueueOutcome.SessionNotFound:
break;
default:
_logger.LogError(
"V2 cascade enqueue returned unexpected outcome {Outcome}. tenant={TenantId} session={SessionId}",
result.Outcome, tenantId, sessionId);
break;
}
return await WriteResponse(req, status, body);
}
///
/// Pure status mapping for the V2 enqueue outcomes. Exposed as a public static so tests
/// can verify the contract without HTTP plumbing.
///
public static HttpStatusCode MapEnqueueOutcomeToStatus(SessionDeletionEnqueueOutcome outcome) => outcome switch
{
SessionDeletionEnqueueOutcome.Enqueued => HttpStatusCode.Accepted,
SessionDeletionEnqueueOutcome.AlreadyInFlight => HttpStatusCode.Conflict,
SessionDeletionEnqueueOutcome.Poisoned => HttpStatusCode.Conflict,
SessionDeletionEnqueueOutcome.KillSwitchActive => HttpStatusCode.ServiceUnavailable,
SessionDeletionEnqueueOutcome.CasExhausted => HttpStatusCode.ServiceUnavailable,
SessionDeletionEnqueueOutcome.SessionNotFound => HttpStatusCode.NotFound,
_ => HttpStatusCode.InternalServerError,
};
///
/// Builds the JSON body for a V2-enqueue response. Internal so the test project
/// can assert the shape without going through HttpResponseData mock plumbing.
///
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
{
SessionDeletionEnqueueOutcome.Enqueued => new
{
success = true,
status = "queued",
manifestId = result.ManifestId,
message = "Cascade deletion queued; worker will drain asynchronously.",
},
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
{
success = false,
message = "A cascade for this session is already in flight.",
deletionState = result.ExistingState,
manifestId = result.ManifestId,
hint = "cascade_already_in_flight",
},
SessionDeletionEnqueueOutcome.Poisoned => new
{
success = false,
message = "Cascade is poisoned; recover via POST /api/global/sessions/{id}/restore before retrying delete.",
deletionState = result.ExistingState,
manifestId = result.ManifestId,
hint = "cascade_poisoned_use_restore",
},
SessionDeletionEnqueueOutcome.KillSwitchActive => new
{
success = false,
message = "Session deletion is temporarily disabled by global kill-switch.",
hint = "kill_switch_active",
},
SessionDeletionEnqueueOutcome.CasExhausted => new
{
success = false,
message = "Cascade enqueue exhausted retries; please retry shortly.",
hint = "cas_exhausted_retry_later",
},
SessionDeletionEnqueueOutcome.SessionNotFound => new
{
success = false,
message = $"Session {sessionId} not found",
},
_ => new
{
success = false,
message = "Internal server error",
},
};
private static async Task WriteResponse(HttpRequestData req, HttpStatusCode status, object body)
{
var response = req.CreateResponse(status);
await response.WriteAsJsonAsync(body);
return response;
}
}
}