diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index af57c12..387db94 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -11,12 +11,14 @@ public static class BrokerApi public const string PackageRequestKind = "PackageRequest"; public const string StatusRequestKind = "StatusRequest"; + public const string CancelRequestKind = "CancelRequest"; public const string HealthResponseKind = "HealthResponse"; public const string CapabilitiesResponseKind = "CapabilitiesResponse"; public const string EvaluationResponseKind = "EvaluationResponse"; public const string ExecutionResponseKind = "ExecutionResponse"; public const string StatusResponseKind = "StatusResponse"; + public const string CancelResponseKind = "CancelResponse"; public const string ErrorResponseKind = "ErrorResponse"; internal static string ValidateMessageKind(string? value, string expected, string propertyName) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index cdf9550..5d87806 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -40,22 +40,26 @@ public static string Serialize(T value) => private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonSerializerContext.Default.PackageRequest) : typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonSerializerContext.Default.StatusRequest) : + typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonSerializerContext.Default.CancelRequest) : typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonSerializerContext.Default.HealthResponse) : typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonSerializerContext.Default.CapabilitiesResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonSerializerContext.Default.StatusResponse) : + typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonSerializerContext.Default.CancelResponse) : typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonSerializerContext.Default.ErrorResponse) : throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated."); private static JsonTypeInfo StrictTypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.PackageRequest) : typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusRequest) : + typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelRequest) : typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.HealthResponse) : typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusResponse) : + typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelResponse) : typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ErrorResponse) : throw new NotSupportedException($"Strict broker JSON deserialization for {typeof(T).FullName} is not source-generated."); @@ -68,11 +72,13 @@ private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => WriteIndented = false)] [JsonSerializable(typeof(PackageRequest))] [JsonSerializable(typeof(StatusRequest))] +[JsonSerializable(typeof(CancelRequest))] [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(CapabilitiesResponse))] [JsonSerializable(typeof(EvaluationResponse))] [JsonSerializable(typeof(ExecutionResponse))] [JsonSerializable(typeof(StatusResponse))] +[JsonSerializable(typeof(CancelResponse))] [JsonSerializable(typeof(ErrorResponse))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] @@ -85,11 +91,13 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PackageRequest))] [JsonSerializable(typeof(StatusRequest))] +[JsonSerializable(typeof(CancelRequest))] [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(CapabilitiesResponse))] [JsonSerializable(typeof(EvaluationResponse))] [JsonSerializable(typeof(ExecutionResponse))] [JsonSerializable(typeof(StatusResponse))] +[JsonSerializable(typeof(CancelResponse))] [JsonSerializable(typeof(ErrorResponse))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/CancelModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/CancelModels.cs new file mode 100644 index 0000000..ab6d787 --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Api/CancelModels.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Now.Policy.Api; + +/// Request body for canceling a previously submitted operation. +public sealed class CancelRequest +{ + private const string Kind = BrokerApi.CancelRequestKind; + private string _requestKind = Kind; + + [JsonPropertyName("RequestKind")] + [JsonRequired] + public string RequestKind + { + get => _requestKind; + set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind)); + } + + [JsonPropertyName("RequestVersion")] + public string RequestVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("OperationId")] + public string OperationId { get; set; } = ""; + + [JsonPropertyName("Client")] + public ClientContext Client { get; set; } = new(); +} + +/// Response to a cancel request. +/// +/// Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a +/// non-terminal operation to and reports the resulting +/// status. Clients should poll the status endpoint until the operation reaches a terminal status +/// (, or / +/// when the process ends first). +/// +public sealed class CancelResponse +{ + private const string Kind = BrokerApi.CancelResponseKind; + private string _responseKind = Kind; + + [JsonPropertyName("ResponseKind")] + [JsonRequired] + public string ResponseKind + { + get => _responseKind; + set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind)); + } + + [JsonPropertyName("ResponseVersion")] + public string ResponseVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("Server")] + public ServerContext Server { get; set; } = new(); + + [JsonPropertyName("OperationId")] + public string OperationId { get; set; } = ""; + + [JsonPropertyName("RequestId")] + public string RequestId { get; set; } = ""; + + /// Status of the operation after the cancel request was applied. + [JsonPropertyName("Status")] + public OperationStatus Status { get; set; } + + /// Human-readable message about the cancelation outcome. + [JsonPropertyName("Message")] + public string? Message { get; set; } +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 8e13592..524fee4 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -87,6 +87,8 @@ public enum OperationStatus Running, Completed, Failed, + Canceling, + Canceled, } /// Broker readiness state reported by the health endpoint. diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 4c360ab..8cd59ea 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -149,6 +149,94 @@ public async Task QueryStatus_populates_client_context() Assert.False(clientContext.TryGetProperty("ApiVersion", out _)); } + [Fact] + public async Task Cancel_populates_client_context() + { + var transport = new FakeBrokerTransport( + CapabilitiesResponse, + """ + {"ResponseKind":"CancelResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"b6cd88d1-9e32-49dd-b53f-e9dad34ad915","Status":"Canceling","Message":"cancelation requested"} + """); + var client = CreateClient(transport); + + var response = await client.Cancel(new OperationCancelQuery { OperationId = "operation:123" }); + var sent = transport.Requests[1]; + using var sentBody = JsonDocument.Parse(sent.Body!); + + Assert.Equal("/v1/capabilities", transport.Requests[0].Path); + Assert.Equal("/v1/package-operations/cancel", sent.Path); + Assert.Equal(BrokerApi.CancelRequestKind, sentBody.RootElement.GetProperty("RequestKind").GetString()); + Assert.Equal(BrokerApi.Version, sentBody.RootElement.GetProperty("RequestVersion").GetString()); + Assert.Equal("operation:123", sentBody.RootElement.GetProperty("OperationId").GetString()); + Assert.Equal(OperationStatus.Canceling, response.Status); + + var clientContext = sentBody.RootElement.GetProperty("Client"); + Assert.Equal("HttpNamedPipe", clientContext.GetProperty("Transport").GetString()); + Assert.Equal("DEVOLUTIONS\\bob", clientContext.GetProperty("EffectiveUser").GetString()); + } + + [Fact] + public async Task ExecuteAndWait_treats_canceled_status_as_terminal() + { + var transport = new FakeBrokerTransport( + CapabilitiesResponse, + """ + {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} + """, + """ + {"ResponseKind":"StatusResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceled","Message":"operation was canceled"} + """); + var client = CreateClient(transport); + + var status = await client.ExecuteAndWait( + new PackageOperationRequest + { + Operation = Operation.Install, + Manager = ManagerName.Winget, + Source = new RequestSource { Name = "winget" }, + Package = new RequestPackage { Id = "Microsoft.VisualStudioCode" }, + }, + pollIntervalMs: 1); + + Assert.Equal(OperationStatus.Canceled, status.Status); + } + + [Fact] + public async Task ExecuteAndWait_requests_broker_cancelation_when_token_is_canceled() + { + var transport = new FakeBrokerTransport( + CapabilitiesResponse, + """ + {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} + """, + """ + {"ResponseKind":"CancelResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceling"} + """); + var client = CreateClient(transport); + using var cts = new CancellationTokenSource(); + + var pending = client.ExecuteAndWait( + new PackageOperationRequest + { + Operation = Operation.Install, + Manager = ManagerName.Winget, + Source = new RequestSource { Name = "winget" }, + Package = new RequestPackage { Id = "Microsoft.VisualStudioCode" }, + }, + cts.Token, + pollIntervalMs: 300_000); + + // Wait for the execute request to be sent, then cancel while the client is between polls. + while (transport.Requests.Count < 2) + { + await Task.Delay(1); + } + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => pending); + Assert.Equal("/v1/package-operations/cancel", transport.Requests[^1].Path); + } + [Fact] public async Task Evaluate_rejects_unsupported_capability_before_operation_request() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs index 7d2ddf1..8bac24d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs @@ -22,6 +22,11 @@ public async Task PackageRequest_round_trips_and_validates(string path) public async Task StatusRequest_round_trips_and_validates(string path) => await AssertRoundTrip(path, await TestData.SchemaAsync("StatusRequest")); + [Theory] + [MemberData(nameof(TestData.CancelRequestSamples), MemberType = typeof(TestData))] + public async Task CancelRequest_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("CancelRequest")); + [Theory] [MemberData(nameof(TestData.ResponseSamples), MemberType = typeof(TestData))] public async Task EvaluationResponse_round_trips_and_validates(string path) @@ -37,6 +42,11 @@ public async Task ExecutionResponse_round_trips_and_validates(string path) public async Task StatusResponse_round_trips_and_validates(string path) => await AssertRoundTrip(path, await TestData.SchemaAsync("StatusResponse")); + [Theory] + [MemberData(nameof(TestData.CancelResponseSamples), MemberType = typeof(TestData))] + public async Task CancelResponse_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("CancelResponse")); + [Theory] [MemberData(nameof(TestData.HealthResponseSamples), MemberType = typeof(TestData))] public async Task HealthResponse_round_trips_and_validates(string path) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs index 0ec84a6..bc6007f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs @@ -142,6 +142,7 @@ _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, public static IEnumerable RequestSamples() => JsonFiles(Path.Combine(SamplesDir, "requests")) .Where(f => !Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal)) + .Where(f => !Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal)) .Where(f => !IsInvalidRequestSample(f)) .Select(f => new object[] { f }); @@ -150,9 +151,15 @@ public static IEnumerable StatusRequestSamples() => .Where(f => Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal)) .Select(f => new object[] { f }); + public static IEnumerable CancelRequestSamples() => + JsonFiles(Path.Combine(SamplesDir, "requests")) + .Where(f => Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + public static IEnumerable ResponseSamples() => JsonFiles(Path.Combine(SamplesDir, "responses")) .Where(f => !Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal)) + .Where(f => !Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("execution-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("health-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("capabilities", StringComparison.Ordinal)) @@ -168,6 +175,11 @@ public static IEnumerable StatusResponseSamples() => .Where(f => Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal)) .Select(f => new object[] { f }); + public static IEnumerable CancelResponseSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + public static IEnumerable HealthResponseSamples() => JsonFiles(Path.Combine(SamplesDir, "responses")) .Where(f => Path.GetFileName(f).StartsWith("health-", StringComparison.Ordinal)) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index 3c16963..ad53bdf 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -110,7 +110,11 @@ private Task Execute(PackageRequest request, CancellationToke /// /// Submit a package operation and poll until it reaches a terminal status - /// ( or ). + /// (, , + /// or ). + /// When is canceled after the operation was submitted, + /// a best-effort broker-side cancelation is issued before the + /// is propagated. /// public async Task ExecuteAndWait( PackageOperationRequest request, @@ -137,18 +141,79 @@ public async Task ExecuteAndWait( "/v1/package-operations/execute"); } - while (true) + var operationId = executeResponse.Operation.OperationId; + + try { - await Task.Delay(pollIntervalMs, cancellationToken).ConfigureAwait(false); + while (true) + { + await Task.Delay(pollIntervalMs, cancellationToken).ConfigureAwait(false); - var status = await QueryStatus(new OperationStatusQuery { OperationId = executeResponse.Operation.OperationId }, cancellationToken) - .ConfigureAwait(false); + var status = await QueryStatus(new OperationStatusQuery { OperationId = operationId }, cancellationToken) + .ConfigureAwait(false); - if (status.Status is OperationStatus.Completed or OperationStatus.Failed) - { - return status; + if (status.Status is OperationStatus.Completed or OperationStatus.Failed or OperationStatus.Canceled) + { + return status; + } } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await TryCancelOperation(operationId).ConfigureAwait(false); + throw; + } + } + + /// Request cancelation of a previously submitted package operation. + /// + /// Cancelation is asynchronous: a successful response with + /// means the broker accepted the request and is + /// terminating the operation. Poll until a terminal status is reached. + /// + public async Task Cancel( + OperationCancelQuery request, + CancellationToken cancellationToken = default) + { + var cancelRequest = CreateCancelRequest(request); + + var capabilities = await GetCachedCapabilities(cancellationToken).ConfigureAwait(false); + EnsureTransportSupported(capabilities, _transport.Kind, "cancel operation", "/v1/package-operations/cancel"); + + var body = BrokerJson.Serialize(cancelRequest); + EnsureRequestBodySize(body, capabilities, "/v1/package-operations/cancel"); + + var headers = new Dictionary + { + ["Content-Type"] = JsonMediaType, + ["Accept"] = JsonMediaType, + }; + + var response = await SendRequest( + "POST", + "/v1/package-operations/cancel", + body, + headers, + cancellationToken).ConfigureAwait(false); + + return DeserializeResponse(response, "cancel", "/v1/package-operations/cancel"); + } + + private async Task TryCancelOperation(string operationId) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var response = await Cancel(new OperationCancelQuery { OperationId = operationId }, timeout.Token) + .ConfigureAwait(false); + Trace?.Invoke($"Requested broker-side cancelation of operation {operationId}: {response.Status}"); + } + catch (Exception ex) + { + // Best-effort: the caller is already canceling; never mask the original cancelation, + // regardless of what the (possibly user-provided) transport throws. + Trace?.Invoke($"Failed to cancel broker operation {operationId}: {ex.Message}"); + } } /// Query the status of a previously submitted package operation. @@ -237,6 +302,19 @@ private StatusRequest CreateStatusRequest(OperationStatusQuery request) }; } + private CancelRequest CreateCancelRequest(OperationCancelQuery request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.OperationId); + + return new CancelRequest + { + RequestVersion = BrokerApi.Version, + OperationId = request.OperationId, + Client = CreateClientContext(), + }; + } + private async Task SendPackageOperation( PackageRequest request, string endpoint, diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/OperationCancelQuery.cs b/policies/dotnet/Devolutions.Now.Policy.Client/OperationCancelQuery.cs new file mode 100644 index 0000000..1308e0e --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Client/OperationCancelQuery.cs @@ -0,0 +1,7 @@ +namespace Devolutions.Now.Policy.Client; + +/// Client-facing operation cancel request. Client context is filled by . +public sealed class OperationCancelQuery +{ + public required string OperationId { get; init; } +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 65c54af..2a3e945 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -27,6 +27,7 @@ The main surface is `BrokerClient`: - `Execute` sends `POST /v1/package-operations/execute`. - `ExecuteAndWait` submits an operation and polls status until a terminal state. - `QueryStatus` sends `POST /v1/package-operations/get-status`. +- `Cancel` sends `POST /v1/package-operations/cancel` to request cancelation of an in-flight operation. Transport is abstracted behind `IBrokerTransport`, which exchanges HTTP-style `BrokerTransportRequest` and `BrokerTransportResponse` values. `NamedPipeBrokerTransport` is the default implementation and sends HTTP/1.1 over a Windows named pipe. Tests and future transports can inject their own transport through `BrokerClientOptions.Transport`. @@ -55,6 +56,7 @@ The public client methods accept client-facing wrapper types instead of raw wire - `PackageOperationRequest` omits `ClientContext` and lets the client fill it. - `OperationStatusQuery` omits `ClientContext` and only requires the operation id. +- `OperationCancelQuery` omits `ClientContext` and only requires the operation id. For transport-independent message identification, request DTOs serialize fixed `RequestKind` discriminators automatically while the client fills `RequestVersion` at the top level of diff --git a/policies/rust/now-policy-api/openapi/now-policy-api.yaml b/policies/rust/now-policy-api/openapi/now-policy-api.yaml index f057d30..2adf68d 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -134,6 +134,39 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /v1/package-operations/cancel: + post: + summary: Cancel package operation + description: 'Requests cancelation of a previously submitted package operation. Cancelation is asynchronous: poll the status endpoint until the operation reaches a terminal status.' + requestBody: + description: Request body for canceling a previously submitted operation. + content: + application/json: + schema: + $ref: '#/components/schemas/CancelRequest' + required: true + responses: + '200': + description: |- + Response to a cancel request. + + Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a non-terminal operation to `Canceling` and reports the resulting status. Clients should poll the status endpoint until the operation reaches a terminal status (`Canceled`, or `Completed`/`Failed` when the process ends first). + content: + application/json: + schema: + $ref: '#/components/schemas/CancelResponse' + '400': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: ApiVersion: @@ -153,6 +186,75 @@ components: type: string maxLength: 16384 pattern: ^[A-Za-z0-9+/]*={0,2}$ + CancelRequest: + description: Request body for canceling a previously submitted operation. + type: object + required: + - Client + - OperationId + - RequestKind + - RequestVersion + properties: + Client: + description: Client context used to authenticate the cancel request. + $ref: '#/components/schemas/ClientContext' + OperationId: + description: Server-issued stable operation identifier. + $ref: '#/components/schemas/ResourceId' + RequestKind: + description: Request discriminator. + $ref: '#/components/schemas/CancelRequestKind' + RequestVersion: + description: Client-side API version used to construct the request. + $ref: '#/components/schemas/ApiVersion' + additionalProperties: false + CancelRequestKind: + type: string + pattern: ^CancelRequest$ + CancelResponse: + description: |- + Response to a cancel request. + + Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a non-terminal operation to `Canceling` and reports the resulting status. Clients should poll the status endpoint until the operation reaches a terminal status (`Canceled`, or `Completed`/`Failed` when the process ends first). + type: object + required: + - OperationId + - RequestId + - ResponseKind + - ResponseVersion + - Server + - Status + properties: + Message: + description: Human-readable message about the cancelation outcome. + type: string + maxLength: 2048 + nullable: true + OperationId: + description: Server-issued stable operation identifier. + $ref: '#/components/schemas/ResourceId' + RequestId: + description: The original request id associated with the operation. + $ref: '#/components/schemas/ResourceId' + ResponseKind: + description: Response discriminator. + $ref: '#/components/schemas/CancelResponseKind' + ResponseVersion: + description: Server-side API version used to construct the response. + $ref: '#/components/schemas/ApiVersion' + Server: + description: Server context. + $ref: '#/components/schemas/ServerContext' + Status: + description: |- + Status of the operation after the cancel request was applied. + + `Canceling` when the cancelation was accepted for an in-flight operation; the terminal status when the operation already finished. + $ref: '#/components/schemas/OperationStatus' + additionalProperties: false + CancelResponseKind: + type: string + pattern: ^CancelResponse$ CapabilitiesResponse: description: Response body for `GET /v1/capabilities`. type: object @@ -586,6 +688,10 @@ components: type: string enum: - Running + - description: Cancelation was requested; the process is being terminated. + type: string + enum: + - Canceling - description: Process exited successfully (exit code 0). type: string enum: @@ -594,6 +700,10 @@ components: type: string enum: - Failed + - description: Operation was canceled at the client's request. + type: string + enum: + - Canceled OperationSubmission: description: Execution submission returned for allowed execute requests. type: object diff --git a/policies/rust/now-policy-api/src/cancel.rs b/policies/rust/now-policy-api/src/cancel.rs new file mode 100644 index 0000000..b6c47f9 --- /dev/null +++ b/policies/rust/now-policy-api/src/cancel.rs @@ -0,0 +1,65 @@ +//! Operation cancelation request and response models. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::api::{ClientContext, ServerContext}; +use super::enums::OperationStatus; +use super::{ApiVersion, CancelRequestKind, CancelResponseKind, ResourceId}; + +/// Request body for canceling a previously submitted operation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "CancelRequest")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct CancelRequest { + /// Request discriminator. + pub request_kind: CancelRequestKind, + + /// Client-side API version used to construct the request. + pub request_version: ApiVersion, + + /// Server-issued stable operation identifier. + pub operation_id: ResourceId, + + /// Client context used to authenticate the cancel request. + pub client: ClientContext, +} + +/// Response to a cancel request. +/// +/// Cancelation is asynchronous and idempotent: the broker acknowledges the request by +/// moving a non-terminal operation to `Canceling` and reports the resulting status. +/// Clients should poll the status endpoint until the operation reaches a terminal +/// status (`Canceled`, or `Completed`/`Failed` when the process ends first). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "CancelResponse")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct CancelResponse { + /// Response discriminator. + pub response_kind: CancelResponseKind, + + /// Server-side API version used to construct the response. + pub response_version: ApiVersion, + + /// Server context. + pub server: ServerContext, + + /// Server-issued stable operation identifier. + pub operation_id: ResourceId, + + /// The original request id associated with the operation. + pub request_id: ResourceId, + + /// Status of the operation after the cancel request was applied. + /// + /// `Canceling` when the cancelation was accepted for an in-flight operation; + /// the terminal status when the operation already finished. + pub status: OperationStatus, + + /// Human-readable message about the cancelation outcome. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 2048))] + pub message: Option, +} diff --git a/policies/rust/now-policy-api/src/enums.rs b/policies/rust/now-policy-api/src/enums.rs index 7b3413c..3ad8074 100644 --- a/policies/rust/now-policy-api/src/enums.rs +++ b/policies/rust/now-policy-api/src/enums.rs @@ -85,10 +85,21 @@ pub enum OperationStatus { Starting, /// Process is running. Running, + /// Cancelation was requested; the process is being terminated. + Canceling, /// Process exited successfully (exit code 0). Completed, /// Process failed (non-zero exit, timeout, or launch failure). Failed, + /// Operation was canceled at the client's request. + Canceled, +} + +impl OperationStatus { + /// Whether this status is terminal (the operation will not change state anymore). + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Canceled) + } } /// Structured machine-readable error code. diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index cedf644..9c3988a 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -4,6 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub mod api; +pub mod cancel; pub mod capabilities; pub mod enums; pub mod evaluate; @@ -14,6 +15,7 @@ mod policy_compat; pub mod status; pub use api::*; +pub use cancel::*; pub use capabilities::*; pub use enums::*; pub use evaluate::*; @@ -26,12 +28,14 @@ pub const DEFAULT_PIPE_NAME: &str = "Devolutions.Now.PackageBroker.v1"; pub const PACKAGE_REQUEST_KIND: &str = "PackageRequest"; pub const STATUS_REQUEST_KIND: &str = "StatusRequest"; +pub const CANCEL_REQUEST_KIND: &str = "CancelRequest"; pub const HEALTH_RESPONSE_KIND: &str = "HealthResponse"; pub const CAPABILITIES_RESPONSE_KIND: &str = "CapabilitiesResponse"; pub const EVALUATION_RESPONSE_KIND: &str = "EvaluationResponse"; pub const EXECUTION_RESPONSE_KIND: &str = "ExecutionResponse"; pub const STATUS_RESPONSE_KIND: &str = "StatusResponse"; +pub const CANCEL_RESPONSE_KIND: &str = "CancelResponse"; pub const ERROR_RESPONSE_KIND: &str = "ErrorResponse"; macro_rules! fixed_string_marker { @@ -89,11 +93,13 @@ macro_rules! fixed_string_marker { fixed_string_marker!(PackageRequestKind, PACKAGE_REQUEST_KIND); fixed_string_marker!(StatusRequestKind, STATUS_REQUEST_KIND); +fixed_string_marker!(CancelRequestKind, CANCEL_REQUEST_KIND); fixed_string_marker!(HealthResponseKind, HEALTH_RESPONSE_KIND); fixed_string_marker!(CapabilitiesResponseKind, CAPABILITIES_RESPONSE_KIND); fixed_string_marker!(EvaluationResponseKind, EVALUATION_RESPONSE_KIND); fixed_string_marker!(ExecutionResponseKind, EXECUTION_RESPONSE_KIND); fixed_string_marker!(StatusResponseKind, STATUS_RESPONSE_KIND); +fixed_string_marker!(CancelResponseKind, CANCEL_RESPONSE_KIND); fixed_string_marker!(ErrorResponseKind, ERROR_RESPONSE_KIND); /// Error returned when a broker protocol newtype fails deserialization validation. diff --git a/policies/rust/now-policy-server-template/assets/samples/requests/cancel-running.request.json b/policies/rust/now-policy-server-template/assets/samples/requests/cancel-running.request.json new file mode 100644 index 0000000..1bb35c2 --- /dev/null +++ b/policies/rust/now-policy-server-template/assets/samples/requests/cancel-running.request.json @@ -0,0 +1,12 @@ +{ + "RequestKind": "CancelRequest", + "RequestVersion": "1.0", + "OperationId": "op-winget-vscode-install-000001", + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/cancel-accepted.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/cancel-accepted.response.json new file mode 100644 index 0000000..570f05f --- /dev/null +++ b/policies/rust/now-policy-server-template/assets/samples/responses/cancel-accepted.response.json @@ -0,0 +1,12 @@ +{ + "ResponseKind": "CancelResponse", + "ResponseVersion": "1.0", + "OperationId": "op-winget-vscode-install-000001", + "RequestId": "req-winget-vscode-install", + "Status": "Canceling", + "Server": { + "Transport": "HttpNamedPipe", + "ServerVersion": "0.1.0" + }, + "Message": "Cancelation requested; the operation is being terminated." +} diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/status-canceled.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/status-canceled.response.json new file mode 100644 index 0000000..ad05969 --- /dev/null +++ b/policies/rust/now-policy-server-template/assets/samples/responses/status-canceled.response.json @@ -0,0 +1,14 @@ +{ + "ResponseKind": "StatusResponse", + "ResponseVersion": "1.0", + "OperationId": "op-winget-vscode-install-000001", + "RequestId": "req-winget-vscode-install", + "Status": "Canceled", + "StartedAt": "2026-05-05T12:00:02Z", + "CompletedAt": "2026-05-05T12:00:09Z", + "Server": { + "Transport": "HttpNamedPipe", + "ServerVersion": "0.1.0" + }, + "Message": "Operation was canceled at the client's request." +} diff --git a/policies/rust/now-policy-server-template/src/mock.rs b/policies/rust/now-policy-server-template/src/mock.rs index 4285057..f574206 100644 --- a/policies/rust/now-policy-server-template/src/mock.rs +++ b/policies/rust/now-policy-server-template/src/mock.rs @@ -6,10 +6,10 @@ use async_trait::async_trait; use crate::server::{MAX_REQUEST_BODY_BYTES, PackageBrokerServer}; use now_policy_api::{ - API_VERSION_STR, Architecture, CapabilitiesResponse, CapabilitiesResponseKind, ErrorCode, ErrorResponse, - ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, - ManagerCapability, ManagerName, Operation, PackageRequest, Scope, ServerContext, StatusRequest, StatusResponse, - Transport, + API_VERSION_STR, Architecture, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, + ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, + HealthResponseKind, HealthStatus, ManagerCapability, ManagerName, Operation, PackageRequest, Scope, ServerContext, + StatusRequest, StatusResponse, Transport, }; /// Deterministic mock broker backed by caller-provided sample responses. @@ -20,6 +20,7 @@ pub struct MockPackageBrokerServer { evaluation_responses: BTreeMap, execution_responses: BTreeMap, status_responses: BTreeMap, + cancel_responses: BTreeMap, } impl MockPackageBrokerServer { @@ -44,6 +45,7 @@ impl MockPackageBrokerServer { evaluation_responses: BTreeMap::new(), execution_responses: BTreeMap::new(), status_responses: BTreeMap::new(), + cancel_responses: BTreeMap::new(), } } @@ -68,6 +70,13 @@ impl MockPackageBrokerServer { self } + #[must_use] + pub fn with_cancel_response(mut self, response: CancelResponse) -> Self { + self.cancel_responses + .insert(response.operation_id.to_string(), response); + self + } + fn missing_response(&self, id: &str) -> ErrorResponse { ErrorResponse { response_kind: ErrorResponseKind, @@ -110,6 +119,13 @@ impl PackageBrokerServer for MockPackageBrokerServer { .cloned() .ok_or_else(|| self.missing_response(&request.operation_id)) } + + async fn cancel(&self, request: CancelRequest) -> Result { + self.cancel_responses + .get(&request.operation_id.to_string()) + .cloned() + .ok_or_else(|| self.missing_response(&request.operation_id)) + } } fn server_context() -> ServerContext { diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 4e4ebb5..a1e532c 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -14,8 +14,8 @@ use axum::response::{IntoResponse, Response}; use serde::Serialize; use now_policy_api::{ - API_VERSION_STR, CapabilitiesResponse, ErrorCode, ErrorResponse, EvaluationResponse, ExecutionResponse, - HealthResponse, PackageRequest, StatusRequest, StatusResponse, + API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, ErrorCode, ErrorResponse, EvaluationResponse, + ExecutionResponse, HealthResponse, PackageRequest, StatusRequest, StatusResponse, }; use schemars::SchemaGenerator; @@ -29,6 +29,7 @@ pub trait PackageBrokerServer: Send + Sync { async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; + async fn cancel(&self, request: CancelRequest) -> Result; } /// Shared package broker server object used by the reusable HTTP router. @@ -65,6 +66,10 @@ fn api_routes() -> ApiRouter { "/v1/package-operations/get-status", post_with(status_handler, status_docs).layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), ) + .api_route( + "/v1/package-operations/cancel", + post_with(cancel_handler, cancel_docs).layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + ) } /// Build the OpenAPI 3 document for the package broker API from the Rust types. @@ -156,6 +161,13 @@ async fn status_handler( broker_result(server.status(request).await) } +async fn cancel_handler( + State(server): State, + Json(request): Json, +) -> Response { + broker_result(server.cancel(request).await) +} + fn broker_result(result: Result) -> Response { match result { Ok(response) => (StatusCode::OK, Json(response)).into_response(), @@ -217,3 +229,14 @@ fn status_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .response::<400, Json>() .response::<404, Json>() } + +fn cancel_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { + op.summary("Cancel package operation") + .description( + "Requests cancelation of a previously submitted package operation. \ + Cancelation is asynchronous: poll the status endpoint until the operation reaches a terminal status.", + ) + .response::<200, Json>() + .response::<400, Json>() + .response::<404, Json>() +} diff --git a/policies/rust/now-policy-server-template/tests/sample_documents.rs b/policies/rust/now-policy-server-template/tests/sample_documents.rs index 1bb9ae6..9ee8ea5 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -5,10 +5,10 @@ use std::path::{Path, PathBuf}; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use now_policy_server_template::{ - API_VERSION_STR, CapabilitiesResponse, CapabilitiesResponseKind, DEFAULT_PIPE_NAME, EvaluationResponse, - ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, ManagerName, - MockPackageBrokerServer, Operation, PackageBrokerServer, PackageRequest, Scope, StatusRequest, StatusRequestKind, - StatusResponse, Transport, api_router, + API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, DEFAULT_PIPE_NAME, + EvaluationResponse, ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, + ManagerName, MockPackageBrokerServer, Operation, PackageBrokerServer, PackageRequest, Scope, StatusRequest, + StatusRequestKind, StatusResponse, Transport, api_router, }; use tower::ServiceExt; @@ -60,6 +60,9 @@ fn assert_response_sample_deserializes(path: &Path) { if name.starts_with("status-") { let _: StatusResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name.starts_with("cancel-") { + let _: CancelResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else if name.starts_with("execution-") { let _: ExecutionResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); @@ -90,6 +93,9 @@ fn all_sample_requests_deserialize() { if name.starts_with("status-") { let _: StatusRequest = serde_json::from_value(load_json_file(&path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name.starts_with("cancel-") { + let _: CancelRequest = serde_json::from_value(load_json_file(&path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else if is_invalid_request_fixture(&path) { assert!( load_package_request(&path).is_err(), @@ -294,6 +300,38 @@ async fn api_router_dispatches_status_request_body_to_package_broker_server() { assert_eq!(actual_status.status, expected_status.status); } +#[tokio::test] +async fn api_router_dispatches_cancel_request_body_to_package_broker_server() { + let request_path = samples_dir().join("requests/cancel-running.request.json"); + let cancel_path = samples_dir().join("responses/cancel-accepted.response.json"); + + let request: CancelRequest = serde_json::from_value(load_json_file(&request_path)).unwrap(); + let expected_cancel: CancelResponse = serde_json::from_value(load_json_file(&cancel_path)).unwrap(); + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_cancel_response(expected_cancel.clone())); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/package-operations/cancel") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let actual_cancel: CancelResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(actual_cancel.operation_id, expected_cancel.operation_id); + assert_eq!( + actual_cancel.status, + now_policy_server_template::OperationStatus::Canceling + ); +} + #[tokio::test] async fn api_router_maps_broker_errors_to_http_status() { let request_path = samples_dir().join("requests/winget-vscode-install.request.json");