From 23ae8c7cd96ba8b2cb0814b87fbdbaf25a2bc72e Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 4 Aug 2026 14:57:35 +0300 Subject: [PATCH] feat(broker): support canceling broker package operations Canceling a package operation that runs through the Devolutions package broker now requests cancelation from the broker, which terminates the underlying package-manager process. Operations reported as Canceled by the broker map to the Canceled veredict. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../UniGetUI.PackageEngine.AgentBroker.csproj | 4 +- .../PackageOperations.cs | 9 ++ .../PackageOperationsTests.cs | 122 +++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj index 949b8e3918..bbebc3cd7d 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj +++ b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index e8cf5e9f47..f5e7d674c8 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -251,6 +251,15 @@ private async Task PerformBrokerOperation() } var output = DisplayBrokerOutput(status.Stdout); + if (status.Status == BrokerOperationStatus.Canceled) + { + string canceledReason = string.IsNullOrWhiteSpace(status.Message) + ? CoreTools.Translate("Operation canceled by user") + : status.Message; + Line($"Operation canceled via broker: {canceledReason}", LineType.Error); + return OperationVeredict.Canceled; + } + if (status.Status == BrokerOperationStatus.Completed) { var veredict = await GetProcessVeredict(status.ExitCode ?? -1, output); diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index 01f9de8af0..ca74681aef 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using System.Text.Json; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; @@ -14,8 +15,16 @@ using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; using UniGetUI.PackageOperations; +using BrokerCapabilitiesResponse = Devolutions.Now.Policy.Api.CapabilitiesResponse; using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind; using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException; +using BrokerExecutionResponse = Devolutions.Now.Policy.Api.ExecutionResponse; +using BrokerManagerCapability = Devolutions.Now.Policy.Api.ManagerCapability; +using BrokerManagerName = Devolutions.Now.Policy.Api.ManagerName; +using BrokerOperationKind = Devolutions.Now.Policy.Api.Operation; +using BrokerOperationStatus = Devolutions.Now.Policy.Api.OperationStatus; +using BrokerOperationSubmission = Devolutions.Now.Policy.Api.OperationSubmission; +using BrokerStatusResponse = Devolutions.Now.Policy.Api.StatusResponse; using BrokerTransportKind = Devolutions.Now.Policy.Api.Transport; using BrokerTransportRequest = Devolutions.Now.Policy.Client.BrokerTransportRequest; using BrokerTransportResponse = Devolutions.Now.Policy.Client.BrokerTransportResponse; @@ -544,6 +553,49 @@ public async Task BrokerOperationFailsWithoutLocalFallbackWhenBrokerDropsAfterPr Assert.Contains(transport.RequestedPaths, path => path != "/v1/health"); } + [Fact] + public async Task BrokerOperationReportedCanceledReturnsCanceledVeredict() + { + bool originalSetting = Settings.Get(Settings.K.UseAgentBroker); + bool localExecutionPrepared = false; + var manager = new PackageManagerBuilder() + .WithName("Chocolatey") + .ConfigureManager(m => + { + m.ExecutablePath = "C:\\test-tools\\choco.exe"; + m.ExecutableArguments = "--test"; + }) + .ConfigureOperation(helper => + helper.ParametersFactory = (package, _, operation) => + { + localExecutionPrepared = true; + return [operation.ToString().ToLowerInvariant(), package.Id]; + }) + .Build(); + var package = new PackageBuilder().WithManager(manager).Build(); + var transport = new FakeBrokerTransport( + healthy: true, + FakeBrokerTransportScenario.CanceledOperation); + PackageOperation.BrokerTransportFactory = () => transport; + Settings.Set(Settings.K.UseAgentBroker, true); + try + { + using var operation = new BrokerProbingInstallPackageOperation(package, new InstallOptions()); + + var veredict = await operation.InvokePerformOperationForTests(); + + Assert.Equal(OperationVeredict.Canceled, veredict); + Assert.Contains("/v1/package-operations/execute", transport.RequestedPaths); + Assert.Contains("/v1/package-operations/get-status", transport.RequestedPaths); + Assert.False(localExecutionPrepared); + } + finally + { + Settings.Set(Settings.K.UseAgentBroker, originalSetting); + PackageOperation.BrokerTransportFactory = null; + } + } + /// /// Runs an install operation against a broker whose transport simulates an outage and /// asserts the policy-enforcement contract: the operation fails with the @@ -659,7 +711,16 @@ public BrokerProbingInstallPackageOperation(IPackage package, InstallOptions opt public Task InvokePerformOperationForTests() => PerformOperation(); } - private sealed class FakeBrokerTransport(bool healthy) : IBrokerTransport + private enum FakeBrokerTransportScenario + { + UnavailableAfterHealth, + CanceledOperation, + } + + private sealed class FakeBrokerTransport( + bool healthy, + FakeBrokerTransportScenario scenario = FakeBrokerTransportScenario.UnavailableAfterHealth + ) : IBrokerTransport { public List RequestedPaths { get; } = []; @@ -676,6 +737,15 @@ public Task Send( return Task.FromResult(new BrokerTransportResponse { StatusCode = 200, Body = "{}" }); } + if (healthy && scenario is FakeBrokerTransportScenario.CanceledOperation) + { + return Task.FromResult(new BrokerTransportResponse + { + StatusCode = 200, + Body = CreateCanceledOperationResponse(request.Path), + }); + } + throw new BrokerClientException( BrokerClientErrorKind.BrokerUnavailable, "Simulated broker outage", @@ -683,6 +753,56 @@ public Task Send( } public void Dispose() { } + + private static string CreateCanceledOperationResponse(string path) + { + if (path == "/v1/capabilities") + { + return JsonSerializer.Serialize(new BrokerCapabilitiesResponse + { + Transports = [BrokerTransportKind.HttpNamedPipe], + Managers = + [ + new BrokerManagerCapability + { + Manager = BrokerManagerName.Chocolatey, + Operations = [BrokerOperationKind.Install], + SupportsCaptureOutput = true, + }, + ], + MaxRequestBodyBytes = 1_000_000, + }); + } + + if (path == "/v1/package-operations/execute") + { + return JsonSerializer.Serialize(new BrokerExecutionResponse + { + Operation = new BrokerOperationSubmission + { + OperationId = "operation-1", + Status = BrokerOperationStatus.Running, + SubmittedAt = DateTimeOffset.UtcNow, + }, + }); + } + + if (path == "/v1/package-operations/get-status") + { + return JsonSerializer.Serialize(new BrokerStatusResponse + { + OperationId = "operation-1", + Status = BrokerOperationStatus.Canceled, + Message = "Canceled by broker", + Stdout = "broker output", + }); + } + + throw new BrokerClientException( + BrokerClientErrorKind.BrokerUnavailable, + "Unexpected broker test path", + path); + } } private class InspectableInstallPackageOperation : InstallPackageOperation