Skip to content

Commit 91d1326

Browse files
feat: Add orchestration termination functionality and enhance cancel operation with instance ID handling
1 parent 86c5411 commit 91d1326

5 files changed

Lines changed: 118 additions & 1 deletion

File tree

src/XtremeIdiots.Portal.Web/Controllers/MapRotationsController.cs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,7 @@ public async Task<IActionResult> GetSyncProgress(string instanceId, Cancellation
726726

727727
[HttpPost]
728728
[ValidateAntiForgeryToken]
729-
public async Task<IActionResult> CancelOperation(Guid operationId, Guid assignmentId, CancellationToken cancellationToken = default)
729+
public async Task<IActionResult> CancelOperation(Guid operationId, Guid assignmentId, string? instanceId, CancellationToken cancellationToken = default)
730730
{
731731
return await ExecuteWithErrorHandlingAsync(async () =>
732732
{
@@ -759,6 +759,24 @@ public async Task<IActionResult> CancelOperation(Guid operationId, Guid assignme
759759

760760
if (updateResult.IsSuccess)
761761
{
762+
// Terminate the durable function orchestration so a new one can be started
763+
if (!string.IsNullOrEmpty(instanceId))
764+
{
765+
// Validate the instance ID belongs to this assignment
766+
var allowedPrefixes = new[]
767+
{
768+
$"maprot-sync-{assignmentId}",
769+
$"maprot-activate-{assignmentId}",
770+
$"maprot-deactivate-{assignmentId}",
771+
$"maprot-remove-{assignmentId}"
772+
};
773+
774+
if (allowedPrefixes.Any(p => string.Equals(instanceId, p, StringComparison.OrdinalIgnoreCase)))
775+
{
776+
await syncApiClient.TerminateOrchestration(instanceId, cancellationToken).ConfigureAwait(false);
777+
}
778+
}
779+
762780
// Also reset the assignment state so the user can retry
763781
var deploymentReset = assignment.DeploymentState is DeploymentState.Syncing or DeploymentState.Removing
764782
? DeploymentState.Failed
@@ -791,4 +809,57 @@ public async Task<IActionResult> CancelOperation(Guid operationId, Guid assignme
791809
return RedirectToAction(nameof(AssignmentStatus), new { id = assignmentId });
792810
}, nameof(CancelOperation)).ConfigureAwait(false);
793811
}
812+
813+
[HttpPost]
814+
[ValidateAntiForgeryToken]
815+
public async Task<IActionResult> TerminateOrchestration(string instanceId, Guid assignmentId, CancellationToken cancellationToken = default)
816+
{
817+
return await ExecuteWithErrorHandlingAsync(async () =>
818+
{
819+
// Validate the instance ID belongs to this assignment
820+
var allowedPrefixes = new[]
821+
{
822+
$"maprot-sync-{assignmentId}",
823+
$"maprot-activate-{assignmentId}",
824+
$"maprot-deactivate-{assignmentId}",
825+
$"maprot-remove-{assignmentId}"
826+
};
827+
828+
if (!allowedPrefixes.Any(p => string.Equals(instanceId, p, StringComparison.OrdinalIgnoreCase)))
829+
return BadRequest("The instance ID does not belong to the specified assignment.");
830+
831+
var assignmentResponse = await repositoryApiClient.MapRotations.V1.GetServerAssignment(assignmentId, cancellationToken).ConfigureAwait(false);
832+
833+
if (assignmentResponse.IsNotFound || assignmentResponse.Result?.Data is null)
834+
return NotFound();
835+
836+
var rotationResponse = await repositoryApiClient.MapRotations.V1.GetMapRotation(assignmentResponse.Result.Data.MapRotationId, cancellationToken).ConfigureAwait(false);
837+
838+
if (rotationResponse.IsNotFound || rotationResponse.Result?.Data is null)
839+
return NotFound();
840+
841+
var authResult = await CheckAuthorizationAsync(
842+
authorizationService,
843+
rotationResponse.Result.Data.GameType,
844+
AuthPolicies.ManageMapRotations,
845+
nameof(TerminateOrchestration),
846+
"MapRotation").ConfigureAwait(false);
847+
848+
if (authResult != null)
849+
return authResult;
850+
851+
var terminated = await syncApiClient.TerminateOrchestration(instanceId, cancellationToken).ConfigureAwait(false);
852+
853+
if (terminated)
854+
{
855+
this.AddAlertSuccess($"Orchestration '{instanceId}' terminated. You can now re-trigger the operation.");
856+
}
857+
else
858+
{
859+
this.AddAlertDanger($"Failed to terminate orchestration '{instanceId}'. It may have already completed or the sync service is unavailable.");
860+
}
861+
862+
return RedirectToAction(nameof(AssignmentStatus), new { id = assignmentId });
863+
}, nameof(TerminateOrchestration)).ConfigureAwait(false);
864+
}
794865
}

src/XtremeIdiots.Portal.Web/Services/ISyncApiClient.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public interface ISyncApiClient
77
Task<SyncTriggerResult> TriggerDeactivate(Guid assignmentId, CancellationToken cancellationToken = default);
88
Task<SyncTriggerResult> TriggerRemove(Guid assignmentId, CancellationToken cancellationToken = default);
99
Task<OrchestrationStatusQueryResult> GetOrchestrationStatus(string instanceId, CancellationToken cancellationToken = default);
10+
Task<bool> TerminateOrchestration(string instanceId, CancellationToken cancellationToken = default);
1011
}
1112

1213
public record SyncTriggerResult(bool Success, string? InstanceId = null, string? Error = null);

src/XtremeIdiots.Portal.Web/Services/NoOpSyncApiClient.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,9 @@ public Task<OrchestrationStatusQueryResult> GetOrchestrationStatus(string instan
2626
{
2727
return Task.FromResult(new OrchestrationStatusQueryResult(OrchestrationStatusQueryOutcome.Error));
2828
}
29+
30+
public Task<bool> TerminateOrchestration(string instanceId, CancellationToken cancellationToken = default)
31+
{
32+
return Task.FromResult(false);
33+
}
2934
}

src/XtremeIdiots.Portal.Web/Services/SyncApiClient.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,34 @@ public async Task<OrchestrationStatusQueryResult> GetOrchestrationStatus(string
6363
}
6464
}
6565

66+
public async Task<bool> TerminateOrchestration(string instanceId, CancellationToken cancellationToken = default)
67+
{
68+
try
69+
{
70+
var tokenResult = await sharedCredential.GetTokenAsync(
71+
new TokenRequestContext([applicationAudience + "/.default"]), cancellationToken).ConfigureAwait(false);
72+
73+
using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl.TrimEnd('/') + $"/api/map-rotations/terminate/{instanceId}");
74+
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.Token);
75+
76+
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
77+
78+
if (response.IsSuccessStatusCode)
79+
{
80+
logger.LogInformation("Successfully terminated orchestration {InstanceId}", instanceId);
81+
return true;
82+
}
83+
84+
logger.LogWarning("Failed to terminate orchestration {InstanceId}, status {StatusCode}", instanceId, (int)response.StatusCode);
85+
return false;
86+
}
87+
catch (Exception ex)
88+
{
89+
logger.LogError(ex, "Failed to terminate orchestration {InstanceId}", instanceId);
90+
return false;
91+
}
92+
}
93+
6694
private async Task<SyncTriggerResult> TriggerOrchestration(string path, CancellationToken cancellationToken)
6795
{
6896
try

src/XtremeIdiots.Portal.Web/Views/MapRotations/AssignmentStatus.cshtml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,23 @@
263263
@Html.AntiForgeryToken()
264264
<input type="hidden" name="operationId" value="@op.MapRotationAssignmentOperationId" />
265265
<input type="hidden" name="assignmentId" value="@Model.Assignment.MapRotationServerAssignmentId" />
266+
<input type="hidden" name="instanceId" value="@op.DurableFunctionInstanceId" />
266267
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Cancel this operation? The assignment state will be reset so you can retry.')">
267268
<i class="fa-solid fa-ban"></i> Cancel
268269
</button>
269270
</form>
270271
}
272+
@if (op.Status is AssignmentOperationStatus.Cancelled or AssignmentOperationStatus.Failed && !string.IsNullOrEmpty(op.DurableFunctionInstanceId))
273+
{
274+
<form policy="@AuthPolicies.ManageMapRotations" asp-action="TerminateOrchestration" method="post" class="d-inline">
275+
@Html.AntiForgeryToken()
276+
<input type="hidden" name="instanceId" value="@op.DurableFunctionInstanceId" />
277+
<input type="hidden" name="assignmentId" value="@Model.Assignment.MapRotationServerAssignmentId" />
278+
<button type="submit" class="btn btn-outline-warning btn-sm" onclick="return confirm('Terminate the underlying orchestration? This clears the stuck durable function so you can re-trigger.')">
279+
<i class="fa-solid fa-skull-crossbones"></i> Terminate
280+
</button>
281+
</form>
282+
}
271283
</td>
272284
</tr>
273285
@if (isInProgress)

0 commit comments

Comments
 (0)