From 7a47300a7eeb4fc5c3658554771cf4a51558bf2b Mon Sep 17 00:00:00 2001 From: Simon Jones Date: Sun, 3 Nov 2024 15:25:34 +0000 Subject: [PATCH] Respect DAPR_GRPC_PORT environment variable if set externally, allow app-port to be suppressed (#68) * #66 : Workflow sample, respect external Environment Variables on startup, allow AppPort to be ignored * #66 : Always initialize DaprOptions to ensure valid fluent builder initalization * #66 : Unit test for HasAppPort, other cleanup * #66 : Additional unit tests --- all.sln | 24 ++ properties/dapr_sidekick_csharp.props | 6 + .../ActorSample.ActorClient.csproj | 2 +- .../ActorSample.DemoActor.csproj | 4 +- .../ActorSample.IDemoActor.csproj | 2 +- .../AppConfigurationSample.csproj | 4 +- .../ControllerSample/ControllerSample.csproj | 2 +- .../Activities/NotifyActivity.cs | 19 ++ .../Activities/ProcessPaymentActivity.cs | 29 +++ .../Activities/RequestApprovalActivity.cs | 18 ++ .../Activities/ReserveInventoryActivity.cs | 51 ++++ .../Activities/UpdateInventoryActivity.cs | 50 ++++ .../WorkflowConsoleApp/Models.cs | 23 ++ .../WorkflowConsoleApp/Program.cs | 228 ++++++++++++++++++ .../Properties/launchSettings.json | 12 + .../WorkflowConsoleApp.csproj | 25 ++ .../Workflows/OrderProcessingWorkflow.cs | 106 ++++++++ .../WorkflowConsoleApp/appsettings.json | 14 ++ .../WorkflowConsoleApp/demo.http | 17 ++ src/Man.Dapr.Sidekick/DaprConstants.cs | 1 + src/Man.Dapr.Sidekick/Options/DaprOptions.cs | 8 +- .../Options/DaprSidecarOptions.cs | 13 + .../Process/DaprProcessHost.net45.cs | 6 + .../Process/DaprSidecarProcess.cs | 9 +- .../Process/IDaprProcessHost.net45.cs | 7 + .../Process/PortAssignmentBuilder.cs | 32 ++- .../Options/DaprSidecarOptionsTests.cs | 2 + .../Process/DaprPlacementProcessTests.cs | 2 +- .../Process/DaprSchedulerProcessTests.cs | 2 +- .../Process/DaprSentryProcessTests.cs | 2 +- .../Process/DaprSidecarProcessTests.cs | 85 ++++++- .../Process/PortAssignmentBuilderTests.cs | 29 ++- 32 files changed, 804 insertions(+), 30 deletions(-) create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/NotifyActivity.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ProcessPaymentActivity.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/RequestApprovalActivity.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ReserveInventoryActivity.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/UpdateInventoryActivity.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Models.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Program.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Properties/launchSettings.json create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/WorkflowConsoleApp.csproj create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/appsettings.json create mode 100644 samples/Workflow/ConsoleSample/WorkflowConsoleApp/demo.http diff --git a/all.sln b/all.sln index 12a7fb3..6f04831 100644 --- a/all.sln +++ b/all.sln @@ -26,6 +26,9 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Man.Dapr.Sidekick.AspNetCore.Tests", "tests\Man.Dapr.Sidekick.AspNetCore.Tests\Man.Dapr.Sidekick.AspNetCore.Tests.csproj", "{F513F641-20E2-4C47-8F57-95664383004E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{6481CB38-F5E9-4CB4-8E5D-AB3114186E7D}" + ProjectSection(SolutionItems) = preProject + samples\Directory.Build.props = samples\Directory.Build.props + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore", "{DA3D8137-F2DD-465D-81AA-3CA5C75087D2}" EndProject @@ -108,6 +111,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SchedulerSample", "Schedule EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SchedulerSample", "samples\AspNetCore\SchedulerSample\SchedulerSample\SchedulerSample.csproj", "{C0B2943F-3EA8-43A9-8714-7D6B84AD788E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workflow", "Workflow", "{2D362BDC-794A-4B1B-B20A-3E4A7214CADC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ConsoleSample", "ConsoleSample", "{E20CFEC4-3F3F-4BA6-B796-363B76FA5B53}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowConsoleApp", "samples\Workflow\ConsoleSample\WorkflowConsoleApp\WorkflowConsoleApp.csproj", "{56EFBE8B-485A-48A7-8239-24709774E4FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -334,6 +343,18 @@ Global {C0B2943F-3EA8-43A9-8714-7D6B84AD788E}.Release|x64.Build.0 = Release|Any CPU {C0B2943F-3EA8-43A9-8714-7D6B84AD788E}.Release|x86.ActiveCfg = Release|Any CPU {C0B2943F-3EA8-43A9-8714-7D6B84AD788E}.Release|x86.Build.0 = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|x64.Build.0 = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Debug|x86.Build.0 = Debug|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|Any CPU.Build.0 = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|x64.ActiveCfg = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|x64.Build.0 = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|x86.ActiveCfg = Release|Any CPU + {56EFBE8B-485A-48A7-8239-24709774E4FE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -369,6 +390,9 @@ Global {2FC86574-6A81-4E2B-A0D4-78D46528A917} = {AE430C04-78BD-4CAE-86D7-EBC599774D9C} {8FD8AFF0-A56A-4BDC-B40E-F498AA147790} = {DA3D8137-F2DD-465D-81AA-3CA5C75087D2} {C0B2943F-3EA8-43A9-8714-7D6B84AD788E} = {8FD8AFF0-A56A-4BDC-B40E-F498AA147790} + {2D362BDC-794A-4B1B-B20A-3E4A7214CADC} = {6481CB38-F5E9-4CB4-8E5D-AB3114186E7D} + {E20CFEC4-3F3F-4BA6-B796-363B76FA5B53} = {2D362BDC-794A-4B1B-B20A-3E4A7214CADC} + {56EFBE8B-485A-48A7-8239-24709774E4FE} = {E20CFEC4-3F3F-4BA6-B796-363B76FA5B53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E906E97D-7D56-4E02-A13F-1C48AEB47A88} diff --git a/properties/dapr_sidekick_csharp.props b/properties/dapr_sidekick_csharp.props index e75a343..aecf389 100644 --- a/properties/dapr_sidekick_csharp.props +++ b/properties/dapr_sidekick_csharp.props @@ -40,6 +40,12 @@ Man Group + + + false + NU1901;NU1902;NU1903;NU1904 + + diff --git a/samples/Actor/ActorSample/ActorSample.ActorClient/ActorSample.ActorClient.csproj b/samples/Actor/ActorSample/ActorSample.ActorClient/ActorSample.ActorClient.csproj index df0e1df..9ca8fda 100644 --- a/samples/Actor/ActorSample/ActorSample.ActorClient/ActorSample.ActorClient.csproj +++ b/samples/Actor/ActorSample/ActorSample.ActorClient/ActorSample.ActorClient.csproj @@ -6,7 +6,7 @@ - + diff --git a/samples/Actor/ActorSample/ActorSample.DemoActor/ActorSample.DemoActor.csproj b/samples/Actor/ActorSample/ActorSample.DemoActor/ActorSample.DemoActor.csproj index e65cb06..d61c8d3 100644 --- a/samples/Actor/ActorSample/ActorSample.DemoActor/ActorSample.DemoActor.csproj +++ b/samples/Actor/ActorSample/ActorSample.DemoActor/ActorSample.DemoActor.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/samples/Actor/ActorSample/ActorSample.IDemoActor/ActorSample.IDemoActor.csproj b/samples/Actor/ActorSample/ActorSample.IDemoActor/ActorSample.IDemoActor.csproj index 1a2e873..2e810fd 100644 --- a/samples/Actor/ActorSample/ActorSample.IDemoActor/ActorSample.IDemoActor.csproj +++ b/samples/Actor/ActorSample/ActorSample.IDemoActor/ActorSample.IDemoActor.csproj @@ -5,7 +5,7 @@ - + diff --git a/samples/AspNetCore/AppConfigurationSample/AppConfigurationSample/AppConfigurationSample.csproj b/samples/AspNetCore/AppConfigurationSample/AppConfigurationSample/AppConfigurationSample.csproj index e10f5f8..2168ccc 100644 --- a/samples/AspNetCore/AppConfigurationSample/AppConfigurationSample/AppConfigurationSample.csproj +++ b/samples/AspNetCore/AppConfigurationSample/AppConfigurationSample/AppConfigurationSample.csproj @@ -8,10 +8,10 @@ - + - + diff --git a/samples/AspNetCore/ControllerSample/ControllerSample/ControllerSample.csproj b/samples/AspNetCore/ControllerSample/ControllerSample/ControllerSample.csproj index 8416539..9616b27 100644 --- a/samples/AspNetCore/ControllerSample/ControllerSample/ControllerSample.csproj +++ b/samples/AspNetCore/ControllerSample/ControllerSample/ControllerSample.csproj @@ -5,7 +5,7 @@ - + diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/NotifyActivity.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/NotifyActivity.cs new file mode 100644 index 0000000..7c41d4c --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/NotifyActivity.cs @@ -0,0 +1,19 @@ +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowConsoleApp.Activities +{ + public class NotifyActivity(ILoggerFactory loggerFactory) : WorkflowActivity + { + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override Task RunAsync(WorkflowActivityContext context, Notification notification) + { + _logger.LogInformation(notification.Message); + + return Task.FromResult(null); + } + } + + public record Notification(string Message); +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ProcessPaymentActivity.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ProcessPaymentActivity.cs new file mode 100644 index 0000000..6d1cb88 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ProcessPaymentActivity.cs @@ -0,0 +1,29 @@ +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowConsoleApp.Activities +{ + public class ProcessPaymentActivity(ILoggerFactory loggerFactory) : WorkflowActivity + { + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override async Task RunAsync(WorkflowActivityContext context, PaymentRequest req) + { + _logger.LogInformation( + "Processing payment: {requestId} for {amount} {item} at ${currency}", + req.RequestId, + req.Amount, + req.ItemName, + req.Currency); + + // Simulate slow processing + await Task.Delay(TimeSpan.FromSeconds(7)); + + _logger.LogInformation( + "Payment for request ID '{requestId}' processed successfully", + req.RequestId); + + return null; + } + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/RequestApprovalActivity.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/RequestApprovalActivity.cs new file mode 100644 index 0000000..f5d71e2 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/RequestApprovalActivity.cs @@ -0,0 +1,18 @@ +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowConsoleApp.Activities +{ + public class RequestApprovalActivity(ILoggerFactory loggerFactory) : WorkflowActivity + { + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override Task RunAsync(WorkflowActivityContext context, OrderPayload input) + { + var orderId = context.InstanceId; + _logger.LogInformation("Requesting approval for order {orderId}", orderId); + + return Task.FromResult(null); + } + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ReserveInventoryActivity.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ReserveInventoryActivity.cs new file mode 100644 index 0000000..585242f --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/ReserveInventoryActivity.cs @@ -0,0 +1,51 @@ +using Dapr.Client; +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowConsoleApp.Activities +{ + public class ReserveInventoryActivity(ILoggerFactory loggerFactory, DaprClient client) : WorkflowActivity + { + private const string StoreName = "statestore"; + + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override async Task RunAsync(WorkflowActivityContext context, InventoryRequest req) + { + _logger.LogInformation( + "Reserving inventory for order '{requestId}' of {quantity} {name}", + req.RequestId, + req.Quantity, + req.ItemName); + + // Ensure that the store has items + var item = await client.GetStateAsync( + StoreName, + req.ItemName.ToLowerInvariant()); + + // Catch for the case where the statestore isn't setup + if (item == null) + { + // Not enough items. + return new InventoryResult(false, item); + } + + _logger.LogInformation( + "There are {quantity} {name} available for purchase", + item.Quantity, + item.Name); + + // See if there're enough items to purchase + if (item.Quantity >= req.Quantity) + { + // Simulate slow processing + await Task.Delay(TimeSpan.FromSeconds(2)); + + return new InventoryResult(true, item); + } + + // Not enough items. + return new InventoryResult(false, item); + } + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/UpdateInventoryActivity.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/UpdateInventoryActivity.cs new file mode 100644 index 0000000..3f3a0ee --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Activities/UpdateInventoryActivity.cs @@ -0,0 +1,50 @@ +using Dapr.Client; +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowConsoleApp.Activities +{ + public class UpdateInventoryActivity(ILoggerFactory loggerFactory, DaprClient client) : WorkflowActivity + { + private const string StoreName = "statestore"; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override async Task RunAsync(WorkflowActivityContext context, PaymentRequest req) + { + _logger.LogInformation( + "Checking inventory for order '{requestId}' for {amount} {name}", + req.RequestId, + req.Amount, + req.ItemName); + + // Simulate slow processing + await Task.Delay(TimeSpan.FromSeconds(5)); + + // Determine if there are enough Items for purchase + var item = await client.GetStateAsync( + StoreName, + req.ItemName.ToLowerInvariant()); + var newQuantity = item.Quantity - req.Amount; + if (newQuantity < 0) + { + _logger.LogInformation( + "Payment for request ID '{requestId}' could not be processed. Insufficient inventory.", + req.RequestId); + throw new InvalidOperationException($"Not enough '{req.ItemName}' inventory! Requested {req.Amount} but only {item.Quantity} available."); + } + + // Update the statestore with the new amount of the item + await client.SaveStateAsync( + StoreName, + req.ItemName.ToLowerInvariant(), + new InventoryItem(Name: req.ItemName, PerItemCost: item.PerItemCost, Quantity: newQuantity)); + + _logger.LogInformation( + "There are now {quantity} {name} left in stock", + newQuantity, + item.Name); + + return null; + } + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Models.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Models.cs new file mode 100644 index 0000000..a8bce98 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Models.cs @@ -0,0 +1,23 @@ +#pragma warning disable SA1649 // File name should match first type name +namespace WorkflowConsoleApp +{ + public record OrderPayload(string Name, double TotalCost, int Quantity = 1); + + public record InventoryRequest(string RequestId, string ItemName, int Quantity); + + public record InventoryResult(bool Success, InventoryItem OrderPayload); + + public record PaymentRequest(string RequestId, string ItemName, int Amount, double Currency); + + public record OrderResult(bool Processed); + + public record InventoryItem(string Name, double PerItemCost, int Quantity); + + public enum ApprovalResult + { + Unspecified = 0, + Approved = 1, + Rejected = 2, + } +} +#pragma warning restore SA1649 // File name should match first type name diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Program.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Program.cs new file mode 100644 index 0000000..bcebb41 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Program.cs @@ -0,0 +1,228 @@ +using Dapr.Client; +using Dapr.Workflow; +using Man.Dapr.Sidekick; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using WorkflowConsoleApp; +using WorkflowConsoleApp.Activities; +using WorkflowConsoleApp.Workflows; + +const string StoreName = "statestore"; + +// The workflow host is a background service that connects to the sidecar over gRPC +var builder = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => +{ + // Add Serilog + services.AddSerilog(x => x + .ReadFrom.Configuration(context.Configuration) + .WriteTo.Console()); + + // Add Dapr Sidekick + services.AddDaprSidekick(context.Configuration); + + // Add workflows + services.AddDaprWorkflow(options => + { + // Note that it's also possible to register a lambda function as the workflow + // or activity implementation instead of a class. + options.RegisterWorkflow(); + + // These are the activities that get invoked by the workflow(s). + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + }); +}); + +// Dapr uses a random port for gRPC by default. If we don't know what that port +// is (because this app was started separate from dapr), then assume 4001. +// For Workflows we need to use the Environment Variable rather than a Sidekick appsettings.json entry +// because the Workflow client can be initialized before Sidekick has started. +if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DAPR_GRPC_PORT"))) +{ + Environment.SetEnvironmentVariable("DAPR_GRPC_PORT", "4001"); +} + +// Start the app - this is the point where we connect to the Dapr sidecar to +// listen for workflow work-items to execute. +using var host = builder.Build(); +host.Start(); + +// Wait for Sidekick to finish initializing the sidecar +var sidecarHost = host.Services.GetRequiredService(); +while (!await sidecarHost.CheckHealthAsync()) +{ + Thread.Sleep(TimeSpan.FromSeconds(5)); +} + +DaprClient daprClient; +var apiToken = Environment.GetEnvironmentVariable("DAPR_API_TOKEN"); +if (!string.IsNullOrEmpty(apiToken)) +{ + daprClient = new DaprClientBuilder().UseDaprApiToken(apiToken).Build(); +} +else +{ + daprClient = new DaprClientBuilder().Build(); +} + +var baseInventory = new List +{ + new(Name: "Paperclips", PerItemCost: 5, Quantity: 100), + new(Name: "Cars", PerItemCost: 15000, Quantity: 100), + new(Name: "Computers", PerItemCost: 500, Quantity: 100), +}; + +// Populate the store with items +await RestockInventory(daprClient, baseInventory); + +// Start the input loop +using (daprClient) +{ + var quit = false; + Console.CancelKeyPress += (sender, e) => + { + quit = true; + Console.WriteLine("Shutting down the example."); + }; + + while (!quit) + { + // Get the name of the item to order and make sure we have inventory + var items = string.Join(", ", baseInventory.Select(i => i.Name)); + Console.WriteLine($"Enter the name of one of the following items to order [{items}]."); + Console.WriteLine("To restock items, type 'restock'."); + var itemName = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(itemName)) + { + continue; + } + else if (string.Equals("restock", itemName, StringComparison.OrdinalIgnoreCase)) + { + await RestockInventory(daprClient, baseInventory); + continue; + } + + InventoryItem item = baseInventory.Find(item => string.Equals(item.Name, itemName, StringComparison.OrdinalIgnoreCase)); + if (item == null) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"We don't have {itemName}!"); + Console.ResetColor(); + continue; + } + + Console.WriteLine($"How many {itemName} would you like to purchase?"); + var amountStr = Console.ReadLine().Trim(); + if (!int.TryParse(amountStr, out var amount) || amount <= 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("Invalid input. Assuming you meant to type '1'."); + Console.ResetColor(); + amount = 1; + } + + var daprWorkflowClient = host.Services.GetRequiredService(); + + // Construct the order with a unique order ID + var orderId = $"{itemName.ToLowerInvariant()}-{Guid.NewGuid().ToString()[..8]}"; + var totalCost = amount * item.PerItemCost; + var orderInfo = new OrderPayload(itemName.ToLowerInvariant(), totalCost, amount); + + // Start the workflow using the order ID as the workflow ID + Console.WriteLine($"Starting order workflow '{orderId}' purchasing {amount} {itemName}"); + await daprWorkflowClient.ScheduleNewWorkflowAsync( + name: nameof(OrderProcessingWorkflow), + instanceId: orderId, + input: orderInfo); + + // Wait for the workflow to start and confirm the input + var state = await daprWorkflowClient.WaitForWorkflowStartAsync( + instanceId: orderId); + + Console.WriteLine($"{nameof(OrderProcessingWorkflow)} (ID = {orderId}) started successfully with {state.ReadInputAs()}"); + + // Wait for the workflow to complete + while (true) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + state = await daprWorkflowClient.WaitForWorkflowCompletionAsync( + instanceId: orderId, + cancellation: cts.Token); + break; + } + catch (OperationCanceledException) + { + // Check to see if the workflow is blocked waiting for an approval + state = await daprWorkflowClient.GetWorkflowStateAsync( + instanceId: orderId); + + if (state.ReadCustomStatusAs()?.Contains("Waiting for approval") == true) + { + Console.WriteLine($"{nameof(OrderProcessingWorkflow)} (ID = {orderId}) requires approval. Approve? [Y/N]"); + var approval = Console.ReadLine(); + var approvalResult = ApprovalResult.Unspecified; + if (string.Equals(approval, "Y", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Approving order..."); + approvalResult = ApprovalResult.Approved; + } + else if (string.Equals(approval, "N", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Rejecting order..."); + approvalResult = ApprovalResult.Rejected; + } + + if (approvalResult != ApprovalResult.Unspecified) + { + // Raise the workflow event to the workflow + await daprWorkflowClient.RaiseEventAsync( + instanceId: orderId, + eventName: "ManagerApproval", + eventPayload: approvalResult); + } + + // otherwise, keep waiting + } + } + } + + if (state.RuntimeStatus == WorkflowRuntimeStatus.Completed) + { + var result = state.ReadOutputAs(); + if (result.Processed) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"Order workflow is {state.RuntimeStatus} and the order was processed successfully ({result})."); + Console.ResetColor(); + } + else + { + Console.WriteLine($"Order workflow is {state.RuntimeStatus} but the order was not processed."); + } + } + else if (state.RuntimeStatus == WorkflowRuntimeStatus.Failed) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"The workflow failed - {state.FailureDetails}"); + Console.ResetColor(); + } + + Console.WriteLine(); + } +} + +static async Task RestockInventory(DaprClient daprClient, List inventory) +{ + Console.WriteLine("*** Restocking inventory..."); + foreach (var item in inventory) + { + Console.WriteLine($"*** \t{item.Name}: {item.Quantity}"); + await daprClient.SaveStateAsync(StoreName, item.Name.ToLowerInvariant(), item); + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Properties/launchSettings.json b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Properties/launchSettings.json new file mode 100644 index 0000000..daf820a --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "OrderingWebApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:10080" + } + } +} \ No newline at end of file diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/WorkflowConsoleApp.csproj b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/WorkflowConsoleApp.csproj new file mode 100644 index 0000000..7618a45 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/WorkflowConsoleApp.csproj @@ -0,0 +1,25 @@ + + + + Exe + net8.0 + enable + 612,618 + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs new file mode 100644 index 0000000..0d5a794 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs @@ -0,0 +1,106 @@ +using Dapr.Workflow; +using WorkflowConsoleApp.Activities; + +namespace WorkflowConsoleApp.Workflows +{ + public class OrderProcessingWorkflow : Workflow + { + private readonly WorkflowTaskOptions _defaultActivityRetryOptions = new() + { + // NOTE: Beware that changing the number of retries is a breaking change for existing workflows. + RetryPolicy = new WorkflowRetryPolicy( + maxNumberOfAttempts: 3, + firstRetryInterval: TimeSpan.FromSeconds(5)), + }; + + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + var orderId = context.InstanceId; + + // Notify the user that an order has come through + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Received order {orderId} for {order.Quantity} {order.Name} at ${order.TotalCost}")); + + // Determine if there is enough of the item available for purchase by checking the inventory + var result = await context.CallActivityAsync( + nameof(ReserveInventoryActivity), + new InventoryRequest(RequestId: orderId, order.Name, order.Quantity), + _defaultActivityRetryOptions); + + // If there is insufficient inventory, fail and let the user know. + if (!result.Success) + { + // End the workflow here since we don't have sufficient inventory + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Insufficient inventory for {order.Name}")); + return new OrderResult(Processed: false); + } + + // Require orders over a certain threshold to be approved + if (order.TotalCost > 50000) + { + // Request manager approval for the order + await context.CallActivityAsync(nameof(RequestApprovalActivity), order); + + try + { + // Pause and wait for a manager to approve the order + context.SetCustomStatus("Waiting for approval"); + var approvalResult = await context.WaitForExternalEventAsync( + eventName: "ManagerApproval", + timeout: TimeSpan.FromSeconds(30)); + context.SetCustomStatus($"Approval result: {approvalResult}"); + if (approvalResult == ApprovalResult.Rejected) + { + // The order was rejected, end the workflow here + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification("Order was rejected by approver")); + return new OrderResult(Processed: false); + } + } + catch (TaskCanceledException) + { + // An approval timeout results in automatic order cancellation + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification("Cancelling order because it didn't receive an approval")); + return new OrderResult(Processed: false); + } + } + + // There is enough inventory available so the user can purchase the item(s). Process their payment + await context.CallActivityAsync( + nameof(ProcessPaymentActivity), + new PaymentRequest(RequestId: orderId, order.Name, order.Quantity, order.TotalCost), + _defaultActivityRetryOptions); + + try + { + // There is enough inventory available so the user can purchase the item(s). Process their payment + await context.CallActivityAsync( + nameof(UpdateInventoryActivity), + new PaymentRequest(RequestId: orderId, order.Name, order.Quantity, order.TotalCost), + _defaultActivityRetryOptions); + } + catch (WorkflowTaskFailedException e) + { + // Let them know their payment processing failed + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Order {orderId} Failed! Details: {e.FailureDetails.ErrorMessage}")); + return new OrderResult(Processed: false); + } + + // Let them know their payment was processed + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Order {orderId} has completed!")); + + // End the workflow with a success result + return new OrderResult(Processed: true); + } + } +} diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/appsettings.json b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/appsettings.json new file mode 100644 index 0000000..edd6ff0 --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + }, + "DaprSidekick": { + "Sidecar": { + // Generic host build doesn't listen on an AppPort - disable here so Sidecar doesn't wait for it indefinitely + "HasAppPort": false + } + } +} + diff --git a/samples/Workflow/ConsoleSample/WorkflowConsoleApp/demo.http b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/demo.http new file mode 100644 index 0000000..669cefe --- /dev/null +++ b/samples/Workflow/ConsoleSample/WorkflowConsoleApp/demo.http @@ -0,0 +1,17 @@ +### Start order processing workflow - replace xxx with any id you like +POST http://localhost:3500/v1.0-beta1/workflows/dapr/OrderProcessingWorkflow/start?instanceID=xxx +Content-Type: application/json + +{"Name": "Paperclips", "TotalCost": 99.95, "Quantity": 1} + +### Start order processing workflow - replace xxx with any id you like +POST http://localhost:3500/v1.0-beta1/workflows/dapr/OrderProcessingWorkflow/start?instanceID=xxx +Content-Type: application/json + +{"Name": "Cars", "TotalCost": 10000, "Quantity": 30} + +### Query dapr sidecar - replace xxx with id from the workflow you've created above +GET http://localhost:3500/v1.0-beta1/workflows/dapr/xxx + +### Terminate the workflow - replace xxx with id from the workflow you've created above +POST http://localhost:3500/v1.0-beta1/workflows/dapr/xxx/terminate \ No newline at end of file diff --git a/src/Man.Dapr.Sidekick/DaprConstants.cs b/src/Man.Dapr.Sidekick/DaprConstants.cs index 0cb5b4c..22a51b8 100644 --- a/src/Man.Dapr.Sidekick/DaprConstants.cs +++ b/src/Man.Dapr.Sidekick/DaprConstants.cs @@ -23,6 +23,7 @@ public class DaprConstants // Environment Variables public const string AppApiTokenEnvironmentVariable = "APP_API_TOKEN"; public const string DaprApiTokenEnvironmentVariable = "DAPR_API_TOKEN"; + public const string DaprAppPortEnvironmentVariable = "DAPR_APP_PORT"; public const string DaprCertChainEnvironmentVariable = "DAPR_CERT_CHAIN"; public const string DaprCertKeyEnvironmentVariable = "DAPR_CERT_KEY"; public const string DaprGrpcPortEnvironmentVariable = "DAPR_GRPC_PORT"; diff --git a/src/Man.Dapr.Sidekick/Options/DaprOptions.cs b/src/Man.Dapr.Sidekick/Options/DaprOptions.cs index b951dc8..22e09ba 100644 --- a/src/Man.Dapr.Sidekick/Options/DaprOptions.cs +++ b/src/Man.Dapr.Sidekick/Options/DaprOptions.cs @@ -11,13 +11,13 @@ public DaprOptions() LogLevel = Process.DaprProcessLogger.DebugLevel; } - public DaprSidecarOptions Sidecar { get; set; } + public DaprSidecarOptions Sidecar { get; set; } = new(); - public DaprPlacementOptions Placement { get; set; } + public DaprPlacementOptions Placement { get; set; } = new(); - public DaprSchedulerOptions Scheduler { get; set; } + public DaprSchedulerOptions Scheduler { get; set; } = new(); - public DaprSentryOptions Sentry { get; set; } + public DaprSentryOptions Sentry { get; set; } = new(); /// /// Creates a deep clone of this instance. diff --git a/src/Man.Dapr.Sidekick/Options/DaprSidecarOptions.cs b/src/Man.Dapr.Sidekick/Options/DaprSidecarOptions.cs index 396a09e..2203c84 100644 --- a/src/Man.Dapr.Sidekick/Options/DaprSidecarOptions.cs +++ b/src/Man.Dapr.Sidekick/Options/DaprSidecarOptions.cs @@ -105,6 +105,19 @@ public DaprSidecarOptions() /// public int? DaprInternalGrpcPort { get; set; } + /// + /// Gets or sets a value indicating whether the application opens the . + /// Defaults to true. + /// + /// + /// Most applications integrate with Dapr by listening on a port for building block integrations + /// such as pub/sub and service discovery. However some building blocks such as Workflows do not + /// need this integration and rely on the pure gRPC streaming protocol initiated by Dapr + /// itself over . For these applications an + /// value should not be passed to Dapr else it will wait indefinitely for the port to be opened. + /// + public bool? HasAppPort { get; set; } + /// /// Gets or sets the absolute path to the kubeconfig file (default %USERPROFILE%/.kube/config). /// diff --git a/src/Man.Dapr.Sidekick/Process/DaprProcessHost.net45.cs b/src/Man.Dapr.Sidekick/Process/DaprProcessHost.net45.cs index 3eb00ef..37b900c 100644 --- a/src/Man.Dapr.Sidekick/Process/DaprProcessHost.net45.cs +++ b/src/Man.Dapr.Sidekick/Process/DaprProcessHost.net45.cs @@ -8,6 +8,12 @@ namespace Man.Dapr.Sidekick.Process { public partial class DaprProcessHost { + public async Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + var result = await GetHealthAsync(cancellationToken); + return result.IsHealthy; + } + public async Task GetHealthAsync(CancellationToken cancellationToken) { var client = DaprHttpClientFactory.CreateDaprHttpClient(); diff --git a/src/Man.Dapr.Sidekick/Process/DaprSidecarProcess.cs b/src/Man.Dapr.Sidekick/Process/DaprSidecarProcess.cs index 5febe4b..1350636 100644 --- a/src/Man.Dapr.Sidekick/Process/DaprSidecarProcess.cs +++ b/src/Man.Dapr.Sidekick/Process/DaprSidecarProcess.cs @@ -84,9 +84,9 @@ protected override DaprSidecarOptions GetProcessOptions(DaprOptions daprOptions) protected override void AssignPorts(PortAssignmentBuilder builder) => builder - .Add(x => x.AppPort, 8500) - .Add(x => x.DaprGrpcPort, 50001) - .Add(x => x.DaprHttpPort, 3500) + .Add(x => x.AppPort, 8500, DaprConstants.DaprAppPortEnvironmentVariable) + .Add(x => x.DaprGrpcPort, 50001, DaprConstants.DaprGrpcPortEnvironmentVariable) + .Add(x => x.DaprHttpPort, 3500, DaprConstants.DaprHttpPortEnvironmentVariable) .Add(x => x.MetricsPort, 9090) .Add(x => x.ProfilePort, 7777); @@ -118,7 +118,7 @@ protected override void AddCommandLineArguments(DaprSidecarOptions source, Comma .Add(AllowedOriginsArgument, source.AllowedOrigins) .Add(AppIdArgument, source.AppId) .Add(AppMaxConcurrencyArgument, source.AppMaxConcurrency) - .Add(AppPortArgument, source.AppPort) + .Add(AppPortArgument, source.AppPort, predicate: () => source.HasAppPort != false) .Add(AppProtocolArgument, source.AppProtocol) .Add(AppSslArgument, source.AppSsl) .Add(ControlPlaneAddressArgument, source.ControlPlaneAddress) @@ -145,6 +145,7 @@ protected override void AddCommandLineArguments(DaprSidecarOptions source, Comma protected override void AddEnvironmentVariables(DaprSidecarOptions source, EnvironmentVariableBuilder builder) => builder .Add(DaprConstants.AppApiTokenEnvironmentVariable, source.AppApiToken) .Add(DaprConstants.DaprApiTokenEnvironmentVariable, source.DaprApiToken) + .Add(DaprConstants.DaprAppPortEnvironmentVariable, source.AppPort, () => source.HasAppPort != false) .Add(DaprConstants.DaprCertChainEnvironmentVariable, source.IssuerCertificate) .Add(DaprConstants.DaprCertKeyEnvironmentVariable, source.IssuerKey) .Add(DaprConstants.DaprGrpcPortEnvironmentVariable, source.DaprGrpcPort) diff --git a/src/Man.Dapr.Sidekick/Process/IDaprProcessHost.net45.cs b/src/Man.Dapr.Sidekick/Process/IDaprProcessHost.net45.cs index 88236cd..3f0f8ee 100644 --- a/src/Man.Dapr.Sidekick/Process/IDaprProcessHost.net45.cs +++ b/src/Man.Dapr.Sidekick/Process/IDaprProcessHost.net45.cs @@ -7,6 +7,13 @@ namespace Man.Dapr.Sidekick.Process { public partial interface IDaprProcessHost { + /// + /// Checks the current health of the Dapr Process. Returns true if healthy, else returns false. + /// + /// A token for cancelling the operation. + /// A that will return the value when the operation has completed. + Task CheckHealthAsync(CancellationToken cancellationToken = default); + /// /// Gets the current health of the Dapr Process. /// diff --git a/src/Man.Dapr.Sidekick/Process/PortAssignmentBuilder.cs b/src/Man.Dapr.Sidekick/Process/PortAssignmentBuilder.cs index 3d96c73..bfc2ba8 100644 --- a/src/Man.Dapr.Sidekick/Process/PortAssignmentBuilder.cs +++ b/src/Man.Dapr.Sidekick/Process/PortAssignmentBuilder.cs @@ -9,17 +9,13 @@ namespace Man.Dapr.Sidekick.Process internal class PortAssignmentBuilder where TOptions : Options.DaprProcessOptions { - private class PortInfo + private class PortInfo(Expression> property, int startingPort, string environmentVariable) { - public PortInfo(Expression> property, int startingPort) - { - Property = property; - StartingPort = startingPort; - } + public Expression> Property { get; } = property; - public Expression> Property { get; } + public int StartingPort { get; } = startingPort; - public int StartingPort { get; } + public string EnvironmentVariable { get; } = environmentVariable; } private readonly List _ports = new List(); @@ -44,9 +40,9 @@ public PortAssignmentBuilder() // For testing internal IPortAvailabilityChecker PortAvailabilityChecker { get; } - public PortAssignmentBuilder Add(Expression> property, int startingPort) + public PortAssignmentBuilder Add(Expression> property, int startingPort, string environmentVariable = null) { - _ports.Add(new PortInfo(property, startingPort)); + _ports.Add(new PortInfo(property, startingPort, environmentVariable ?? string.Empty)); return this; } @@ -74,6 +70,22 @@ public void Build(TOptions proposedOptions, TOptions lastSuccessfulOptions, IDap continue; } + // If we have an environment variable defined attempt to get the port from it. + // This overrides default/proposed configuration values. + if (!string.IsNullOrEmpty(port.EnvironmentVariable) && + int.TryParse(Environment.GetEnvironmentVariable(port.EnvironmentVariable), out var environmentPort) && + environmentPort > 0) + { + logger.LogDebug( + "Assigning environment variable {DaprPortEnvironmentVariable} port {DaprPortNumber} for option {DaprPortName}", + port.EnvironmentVariable, + environmentPort, + propertyName); + reservedPorts.Add(environmentPort); + propertyInfo.SetValue(proposedOptions, environmentPort, null); + continue; + } + // If port is already defined, then use it if (proposedValue.HasValue) { diff --git a/tests/Man.Dapr.Sidekick.Tests/Options/DaprSidecarOptionsTests.cs b/tests/Man.Dapr.Sidekick.Tests/Options/DaprSidecarOptionsTests.cs index e0512ea..5d7a3bc 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Options/DaprSidecarOptionsTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Options/DaprSidecarOptionsTests.cs @@ -139,6 +139,7 @@ private static void Compare(DaprSidecarOptions source, DaprSidecarOptions target Assert.That(target.DaprHttpMaxRequestSize, Is.EqualTo(source.DaprHttpMaxRequestSize)); Assert.That(target.DaprHttpPort, Is.EqualTo(source.DaprHttpPort)); Assert.That(target.DaprInternalGrpcPort, Is.EqualTo(source.DaprInternalGrpcPort)); + Assert.That(target.HasAppPort, Is.EqualTo(source.HasAppPort)); Assert.That(target.KubeConfig, Is.EqualTo(source.KubeConfig)); Assert.That(target.MetricsPort, Is.EqualTo(source.MetricsPort)); Assert.That(target.Mode, Is.EqualTo(source.Mode)); @@ -172,6 +173,7 @@ private static void Compare(DaprSidecarOptions source, DaprSidecarOptions target DaprHttpMaxRequestSize = 400, DaprHttpPort = 500, DaprInternalGrpcPort = 600, + HasAppPort = true, KubeConfig = "KubeConfig", MetricsPort = 700, Mode = "Mode", diff --git a/tests/Man.Dapr.Sidekick.Tests/Process/DaprPlacementProcessTests.cs b/tests/Man.Dapr.Sidekick.Tests/Process/DaprPlacementProcessTests.cs index 21cf5db..be5ac31 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Process/DaprPlacementProcessTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Process/DaprPlacementProcessTests.cs @@ -16,7 +16,7 @@ public void Should_create_placement_section_if_null() var p = new MockDaprPlacementProcess(); var options = new DaprOptions(); - Assert.That(options.Placement, Is.Null); + Assert.That(options.Placement, Is.Not.Null); var newOptions = p.GetProcessOptions(options); Assert.That(newOptions, Is.Not.Null); diff --git a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSchedulerProcessTests.cs b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSchedulerProcessTests.cs index 66453e6..f21f4c1 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSchedulerProcessTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSchedulerProcessTests.cs @@ -15,7 +15,7 @@ public void Should_create_scheduler_section_if_null() var p = new MockDaprSchedulerProcess(); var options = new DaprOptions(); - Assert.That(options.Scheduler, Is.Null); + Assert.That(options.Scheduler, Is.Not.Null); var newOptions = p.GetProcessOptions(options); Assert.That(newOptions, Is.Not.Null); diff --git a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSentryProcessTests.cs b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSentryProcessTests.cs index 54578ce..35951b7 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSentryProcessTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSentryProcessTests.cs @@ -15,7 +15,7 @@ public void Should_create_sentry_section_if_null() var p = new MockDaprSentryProcess(); var options = new DaprOptions(); - Assert.That(options.Sentry, Is.Null); + Assert.That(options.Sentry, Is.Not.Null); var newOptions = p.GetProcessOptions(options); Assert.That(newOptions, Is.Not.Null); diff --git a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSidecarProcessTests.cs b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSidecarProcessTests.cs index 4ba1912..7a870c5 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Process/DaprSidecarProcessTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Process/DaprSidecarProcessTests.cs @@ -17,7 +17,7 @@ public void Should_assign_defaults() var p = new MockDaprSidecarProcess(); var options = new DaprOptions(); - Assert.That(options.Sidecar, Is.Null); + Assert.That(options.Sidecar, Is.Not.Null); var newOptions = p.GetProcessOptions(options); Assert.That(newOptions, Is.Not.Null); @@ -129,6 +129,64 @@ public void Should_assign_expected_values() Assert.That(options.MetricsPort, Is.EqualTo(9090)); Assert.That(options.ProfilePort, Is.EqualTo(7777)); } + + [TestCase(false)] + [TestCase(true)] + public void Should_assign_environment_variable_overrides(bool useEnvironmentVariables) + { + var p = new MockDaprSidecarProcess(); + var builder = new PortAssignmentBuilder(new MockPortAvailabilityChecker()); + var options = new DaprSidecarOptions + { + AppPort = 2000, + DaprGrpcPort = 3000, + DaprHttpPort = 4000 + }; + + var logger = Substitute.For(); + + var existingAppPort = Environment.GetEnvironmentVariable(DaprConstants.DaprAppPortEnvironmentVariable); + var existingGrpcPort = Environment.GetEnvironmentVariable(DaprConstants.DaprGrpcPortEnvironmentVariable); + var existingHttpPort = Environment.GetEnvironmentVariable(DaprConstants.DaprHttpPortEnvironmentVariable); + + try + { + if (useEnvironmentVariables) + { + Environment.SetEnvironmentVariable(DaprConstants.DaprAppPortEnvironmentVariable, "1234"); + Environment.SetEnvironmentVariable(DaprConstants.DaprGrpcPortEnvironmentVariable, "98765"); + Environment.SetEnvironmentVariable(DaprConstants.DaprHttpPortEnvironmentVariable, "7345"); + } + + p.AssignPorts(builder); + builder.Build(options, new DaprSidecarOptions(), logger); + + // Should respect environment variables + if (useEnvironmentVariables) + { + // Should use environment variable values + Assert.That(options.AppPort, Is.EqualTo(1234)); + Assert.That(options.DaprGrpcPort, Is.EqualTo(98765)); + Assert.That(options.DaprHttpPort, Is.EqualTo(7345)); + } + else + { + // Should use options values + Assert.That(options.AppPort, Is.EqualTo(2000)); + Assert.That(options.DaprGrpcPort, Is.EqualTo(3000)); + Assert.That(options.DaprHttpPort, Is.EqualTo(4000)); + } + } + finally + { + if (useEnvironmentVariables) + { + Environment.SetEnvironmentVariable(DaprConstants.DaprAppPortEnvironmentVariable, existingAppPort); + Environment.SetEnvironmentVariable(DaprConstants.DaprGrpcPortEnvironmentVariable, existingGrpcPort); + Environment.SetEnvironmentVariable(DaprConstants.DaprHttpPortEnvironmentVariable, existingHttpPort); + } + } + } } public class AssignLocations @@ -196,6 +254,31 @@ public void Should_not_add_empty_sentry_address() Assert.That(builder.ToString(), Does.Not.Contain("sentry-address")); } + [TestCase(null)] + [TestCase(false)] + [TestCase(true)] + public void Should_suppress_appport(bool? hasAppPort) + { + var p = new MockDaprSidecarProcess(); + var builder = new CommandLineArgumentBuilder(); + var options = new DaprSidecarOptions + { + HasAppPort = hasAppPort, + AppPort = 1234 + }; + + p.AddCommandLineArguments(options, builder); + + if (hasAppPort == false) + { + Assert.That(builder.ToString(), Does.Not.Contain("app-port")); + } + else + { + Assert.That(builder.ToString(), Does.Contain("app-port")); + } + } + [Test] public void Should_add_all_arguments() { diff --git a/tests/Man.Dapr.Sidekick.Tests/Process/PortAssignmentBuilderTests.cs b/tests/Man.Dapr.Sidekick.Tests/Process/PortAssignmentBuilderTests.cs index 8f71ce6..7982c5a 100644 --- a/tests/Man.Dapr.Sidekick.Tests/Process/PortAssignmentBuilderTests.cs +++ b/tests/Man.Dapr.Sidekick.Tests/Process/PortAssignmentBuilderTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Man.Dapr.Sidekick.Logging; using NSubstitute; using NUnit.Framework; @@ -135,6 +136,32 @@ public void Should_use_starting_ports() Assert.That(proposedOptions.DaprHttpPort, Is.EqualTo(200)); Assert.That(proposedOptions.MetricsPort, Is.EqualTo(4000)); } + + [Test] + public void Should_use_environment_variable_ports() + { + var logger = Substitute.For(); + var checker = Substitute.For(); + var builder = new PortAssignmentBuilder(checker); + + Environment.SetEnvironmentVariable("DAPRSIDEKICK_TESTS_VALID_PORT", "1234"); + Environment.SetEnvironmentVariable("DAPRSIDEKICK_TESTS_INVALID_PORT", "Not_A_Number"); + + builder + .Add(x => x.DaprGrpcPort, 2000, "DAPRSIDEKICK_TESTS_VALID_PORT") + .Add(x => x.DaprHttpPort, 3000, "DAPRSIDEKICK_TESTS_INVALID_PORT") + .Add(x => x.MetricsPort, 4000, "DAPRSIDEKICK_TESTS_MISSING_PORT"); + + checker.GetAvailablePort(3000, Arg.Any>()).Returns(3000); + checker.GetAvailablePort(4000, Arg.Any>()).Returns(4000); + + var proposedOptions = new DaprSidecarOptions(); + + builder.Build(proposedOptions, null, logger); + Assert.That(proposedOptions.DaprGrpcPort, Is.EqualTo(1234)); + Assert.That(proposedOptions.DaprHttpPort, Is.EqualTo(3000)); + Assert.That(proposedOptions.MetricsPort, Is.EqualTo(4000)); + } } } }