diff --git a/src/ReadyStackGo.Application/Services/MaintenanceObserverConfigMapper.cs b/src/ReadyStackGo.Application/Services/MaintenanceObserverConfigMapper.cs new file mode 100644 index 00000000..69dcdbd7 --- /dev/null +++ b/src/ReadyStackGo.Application/Services/MaintenanceObserverConfigMapper.cs @@ -0,0 +1,140 @@ +using ReadyStackGo.Domain.Deployment.Observers; +using ReadyStackGo.Domain.StackManagement.Manifests; + +namespace ReadyStackGo.Application.Services; + +/// +/// Maps a RsgoMaintenanceObserver (StackManagement domain) to a MaintenanceObserverConfig +/// (Deployment domain), resolving ${VAR} placeholders against a variable dictionary. +/// +public static class MaintenanceObserverConfigMapper +{ + public static MaintenanceObserverConfig? Map( + RsgoMaintenanceObserver? source, + IReadOnlyDictionary variables) + { + if (source == null) + return null; + + if (!ObserverType.TryFromValue(source.Type, out var observerType) || observerType == null) + return null; + + var pollingInterval = ParseTimeSpan(source.PollingInterval) ?? TimeSpan.FromSeconds(30); + + IObserverSettings settings; + + if (observerType == ObserverType.SqlExtendedProperty) + { + var connectionString = ResolveConnectionString(source, variables); + if (string.IsNullOrEmpty(connectionString)) + return null; + + settings = SqlObserverSettings.ForExtendedProperty( + source.PropertyName ?? throw new InvalidOperationException("PropertyName required"), + connectionString); + } + else if (observerType == ObserverType.SqlQuery) + { + var connectionString = ResolveConnectionString(source, variables); + if (string.IsNullOrEmpty(connectionString)) + return null; + + settings = SqlObserverSettings.ForQuery( + source.Query ?? throw new InvalidOperationException("Query required"), + connectionString); + } + else if (observerType == ObserverType.Http) + { + var timeout = ParseTimeSpan(source.Timeout) ?? TimeSpan.FromSeconds(10); + var url = ResolveVariables( + source.Url ?? throw new InvalidOperationException("URL required"), variables); + if (string.IsNullOrEmpty(url)) + return null; + + settings = HttpObserverSettings.Create( + url, + source.Method ?? "GET", + null, + timeout, + source.JsonPath); + } + else if (observerType == ObserverType.File) + { + var mode = source.Mode?.ToLowerInvariant() == "content" + ? FileCheckMode.Content + : FileCheckMode.Exists; + + var path = ResolveVariables( + source.Path ?? throw new InvalidOperationException("Path required"), variables); + if (string.IsNullOrEmpty(path)) + return null; + + settings = mode == FileCheckMode.Content + ? FileObserverSettings.ForContent(path, source.ContentPattern) + : FileObserverSettings.ForExistence(path); + } + else + { + return null; + } + + return MaintenanceObserverConfig.Create( + observerType, + pollingInterval, + source.MaintenanceValue, + source.NormalValue, + settings); + } + + private static string? ResolveConnectionString( + RsgoMaintenanceObserver source, + IReadOnlyDictionary variables) + { + if (!string.IsNullOrEmpty(source.ConnectionString)) + return ResolveVariables(source.ConnectionString, variables); + + if (!string.IsNullOrEmpty(source.ConnectionName) && + variables.TryGetValue(source.ConnectionName, out var connectionString)) + { + return connectionString; + } + + return null; + } + + private static string? ResolveVariables(string template, IReadOnlyDictionary variables) + { + if (string.IsNullOrEmpty(template)) + return template; + + var result = template; + foreach (var kvp in variables) + { + result = result.Replace($"${{{kvp.Key}}}", kvp.Value); + } + + if (result.Contains("${")) + return null; + + return result; + } + + private static TimeSpan? ParseTimeSpan(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + + value = value.Trim().ToLowerInvariant(); + + if (value.EndsWith('s') && int.TryParse(value[..^1], out var seconds)) + return TimeSpan.FromSeconds(seconds); + + if (value.EndsWith('m') && int.TryParse(value[..^1], out var minutes)) + return TimeSpan.FromMinutes(minutes); + + if (value.EndsWith('h') && int.TryParse(value[..^1], out var hours)) + return TimeSpan.FromHours(hours); + + return null; + } +} diff --git a/src/ReadyStackGo.Application/UseCases/Deployments/DeployProduct/DeployProductHandler.cs b/src/ReadyStackGo.Application/UseCases/Deployments/DeployProduct/DeployProductHandler.cs index cc1a5015..70eb619b 100644 --- a/src/ReadyStackGo.Application/UseCases/Deployments/DeployProduct/DeployProductHandler.cs +++ b/src/ReadyStackGo.Application/UseCases/Deployments/DeployProduct/DeployProductHandler.cs @@ -117,6 +117,24 @@ public async Task Handle(DeployProductCommand request, Ca request.SharedVariables, request.ContinueOnError); + // Resolve and attach the product-level maintenance observer config. + // Without this the MaintenanceObserverService has no config to poll. + var observerConfig = MaintenanceObserverConfigMapper.Map( + product.MaintenanceObserver, request.SharedVariables); + if (observerConfig != null) + { + productDeployment.SetMaintenanceObserverConfig(observerConfig); + _logger.LogInformation( + "Product deployment {ProductDeploymentId} wired maintenance observer type={ObserverType}", + productDeploymentId, observerConfig.Type.Value); + } + else if (product.MaintenanceObserver != null) + { + _logger.LogWarning( + "Product {ProductName} defines maintenanceObserver but it could not be mapped (unresolved variables?) — observer disabled", + product.Name); + } + _repository.Add(productDeployment); _repository.SaveChanges(); diff --git a/src/ReadyStackGo.Application/UseCases/Deployments/DeployStack/DeployStackHandler.cs b/src/ReadyStackGo.Application/UseCases/Deployments/DeployStack/DeployStackHandler.cs index d6cab6e2..ea92179f 100644 --- a/src/ReadyStackGo.Application/UseCases/Deployments/DeployStack/DeployStackHandler.cs +++ b/src/ReadyStackGo.Application/UseCases/Deployments/DeployStack/DeployStackHandler.cs @@ -2,8 +2,6 @@ using Microsoft.Extensions.Logging; using ReadyStackGo.Application.Notifications; using ReadyStackGo.Application.Services; -using ReadyStackGo.Domain.Deployment.Observers; -using ReadyStackGo.Domain.StackManagement.Manifests; using ReadyStackGo.Domain.StackManagement.Stacks; using RuntimeConfig = ReadyStackGo.Domain.Deployment.RuntimeConfig; @@ -52,9 +50,8 @@ public async Task Handle(DeployStackCommand request, Cancel : null; // Map MaintenanceObserver from StackManagement to Deployment domain model - var observerConfig = product?.MaintenanceObserver != null - ? MapToDeploymentObserverConfig(product.MaintenanceObserver, request.Variables) - : null; + var observerConfig = MaintenanceObserverConfigMapper.Map( + product?.MaintenanceObserver, request.Variables); // Extract health check configurations from services var healthCheckConfigs = ExtractHealthCheckConfigs(stackDefinition.Services); @@ -196,148 +193,6 @@ private async Task CreateDeploymentNotificationAsync( return null; } - /// - /// Maps RsgoMaintenanceObserver (StackManagement domain) to MaintenanceObserverConfig (Deployment domain). - /// This is the boundary mapping between bounded contexts. - /// - private static MaintenanceObserverConfig? MapToDeploymentObserverConfig( - RsgoMaintenanceObserver source, - Dictionary deploymentVariables) - { - // Parse observer type - if (!ObserverType.TryFromValue(source.Type, out var observerType) || observerType == null) - { - return null; - } - - // Parse polling interval - var pollingInterval = ParseTimeSpan(source.PollingInterval) ?? TimeSpan.FromSeconds(30); - - // Create type-specific settings - IObserverSettings settings; - - if (observerType == ObserverType.SqlExtendedProperty) - { - var connectionString = ResolveConnectionString(source, deploymentVariables); - if (string.IsNullOrEmpty(connectionString)) - return null; - - settings = SqlObserverSettings.ForExtendedProperty( - source.PropertyName ?? throw new InvalidOperationException("PropertyName required"), - connectionString); - } - else if (observerType == ObserverType.SqlQuery) - { - var connectionString = ResolveConnectionString(source, deploymentVariables); - if (string.IsNullOrEmpty(connectionString)) - return null; - - settings = SqlObserverSettings.ForQuery( - source.Query ?? throw new InvalidOperationException("Query required"), - connectionString); - } - else if (observerType == ObserverType.Http) - { - var timeout = ParseTimeSpan(source.Timeout) ?? TimeSpan.FromSeconds(10); - settings = HttpObserverSettings.Create( - source.Url ?? throw new InvalidOperationException("URL required"), - source.Method ?? "GET", - null, // Headers not in RsgoMaintenanceObserver - timeout, - source.JsonPath); - } - else if (observerType == ObserverType.File) - { - var mode = source.Mode?.ToLowerInvariant() == "content" - ? FileCheckMode.Content - : FileCheckMode.Exists; - - settings = mode == FileCheckMode.Content - ? FileObserverSettings.ForContent( - source.Path ?? throw new InvalidOperationException("Path required"), - source.ContentPattern) - : FileObserverSettings.ForExistence( - source.Path ?? throw new InvalidOperationException("Path required")); - } - else - { - return null; - } - - return MaintenanceObserverConfig.Create( - observerType, - pollingInterval, - source.MaintenanceValue, - source.NormalValue, - settings); - } - - /// - /// Resolves connection string from direct value or variable reference. - /// - private static string? ResolveConnectionString( - RsgoMaintenanceObserver source, - Dictionary variables) - { - // Direct connection string - resolve variables if present - if (!string.IsNullOrEmpty(source.ConnectionString)) - { - return ResolveVariables(source.ConnectionString, variables); - } - - // Connection name - look up in deployment variables - if (!string.IsNullOrEmpty(source.ConnectionName) && - variables.TryGetValue(source.ConnectionName, out var connectionString)) - { - return connectionString; - } - - return null; - } - - /// - /// Resolves ${VAR_NAME} placeholders in a template string. - /// - private static string? ResolveVariables(string template, Dictionary variables) - { - if (string.IsNullOrEmpty(template)) - return template; - - var result = template; - foreach (var kvp in variables) - { - result = result.Replace($"${{{kvp.Key}}}", kvp.Value); - } - - // Check if any unresolved placeholders remain - if (result.Contains("${")) - return null; - - return result; - } - - /// - /// Parses time span strings like "30s", "1m", "5m", "1h". - /// - private static TimeSpan? ParseTimeSpan(string? value) - { - if (string.IsNullOrEmpty(value)) - return null; - - value = value.Trim().ToLowerInvariant(); - - if (value.EndsWith('s') && int.TryParse(value[..^1], out var seconds)) - return TimeSpan.FromSeconds(seconds); - - if (value.EndsWith('m') && int.TryParse(value[..^1], out var minutes)) - return TimeSpan.FromMinutes(minutes); - - if (value.EndsWith('h') && int.TryParse(value[..^1], out var hours)) - return TimeSpan.FromHours(hours); - - return null; - } - /// /// Extracts health check configurations from service templates. /// Maps ServiceHealthCheck (StackManagement) to ServiceHealthCheckConfig (Deployment). diff --git a/src/ReadyStackGo.Application/UseCases/Deployments/RedeployProduct/RedeployProductHandler.cs b/src/ReadyStackGo.Application/UseCases/Deployments/RedeployProduct/RedeployProductHandler.cs index 6b6ae054..fb067274 100644 --- a/src/ReadyStackGo.Application/UseCases/Deployments/RedeployProduct/RedeployProductHandler.cs +++ b/src/ReadyStackGo.Application/UseCases/Deployments/RedeployProduct/RedeployProductHandler.cs @@ -16,6 +16,7 @@ namespace ReadyStackGo.Application.UseCases.Deployments.RedeployProduct; public class RedeployProductHandler : IRequestHandler { private readonly IProductDeploymentRepository _repository; + private readonly IProductSourceService _productSourceService; private readonly IMediator _mediator; private readonly IDeploymentService _deploymentService; private readonly IDeploymentNotificationService? _notificationService; @@ -25,6 +26,7 @@ public class RedeployProductHandler : IRequestHandler logger, @@ -33,6 +35,7 @@ public RedeployProductHandler( TimeProvider? timeProvider = null) { _repository = repository; + _productSourceService = productSourceService; _mediator = mediator; _deploymentService = deploymentService; _logger = logger; @@ -72,6 +75,34 @@ public async Task Handle(RedeployProductCommand request, return DeployProductResponse.Failed(ex.Message); } + // Re-resolve the product-level maintenance observer from the current catalog. + // This is how stack.yaml edits to maintenance.observer take effect on redeploy. + if (!string.IsNullOrEmpty(productDeployment.ProductId)) + { + var product = await _productSourceService.GetProductAsync( + productDeployment.ProductId, cancellationToken); + if (product != null) + { + var observerVariables = BuildObserverVariables(productDeployment, request.VariableOverrides); + var observerConfig = MaintenanceObserverConfigMapper.Map( + product.MaintenanceObserver, observerVariables); + productDeployment.SetMaintenanceObserverConfig(observerConfig); + + if (observerConfig != null) + { + _logger.LogInformation( + "Redeploy {ProductDeploymentId} refreshed maintenance observer type={ObserverType}", + productDeployment.Id, observerConfig.Type.Value); + } + else if (product.MaintenanceObserver != null) + { + _logger.LogWarning( + "Product {ProductName} defines maintenanceObserver but it could not be mapped on redeploy (unresolved variables?) — observer disabled", + productDeployment.ProductName); + } + } + } + _repository.Update(productDeployment); _repository.SaveChanges(); @@ -261,6 +292,25 @@ await NotifyStackCompletedAsync( }; } + // Merge shared variables with redeploy overrides for observer placeholder resolution. + // Observer is product-level, so we only use shared values — not per-stack overrides. + private static Dictionary BuildObserverVariables( + ProductDeployment productDeployment, + IReadOnlyDictionary? overrides) + { + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in productDeployment.SharedVariables) + merged[kvp.Key] = kvp.Value; + + if (overrides != null) + { + foreach (var kvp in overrides) + merged[kvp.Key] = kvp.Value; + } + + return merged; + } + private static void FinalizeProductStatus(ProductDeployment productDeployment) { if (productDeployment.Status is ProductDeploymentStatus.Running diff --git a/tests/ReadyStackGo.UnitTests/Application/Deployments/DeployProductHandlerTests.cs b/tests/ReadyStackGo.UnitTests/Application/Deployments/DeployProductHandlerTests.cs index 1040e4b8..c502d7aa 100644 --- a/tests/ReadyStackGo.UnitTests/Application/Deployments/DeployProductHandlerTests.cs +++ b/tests/ReadyStackGo.UnitTests/Application/Deployments/DeployProductHandlerTests.cs @@ -864,4 +864,111 @@ public async Task Handle_Success_CreatesInAppNotification() } #endregion + + #region Maintenance Observer Wiring + + [Fact] + public async Task Handle_ProductHasResolvableObserver_SetsConfigOnProductDeployment() + { + var product = CreateProductWithObserver( + new global::ReadyStackGo.Domain.StackManagement.Manifests.RsgoMaintenanceObserver + { + Type = "sqlExtendedProperty", + PropertyName = "ams-MaintenanceMode", + ConnectionString = "Server=${DB_SERVER};Database=${DB_NAME};", + MaintenanceValue = "1", + NormalValue = "0", + PollingInterval = "30s" + }); + + SetupProductFound(product); + SetupNoExistingDeployment(); + SetupAllStacksSucceed(); + + ProductDeployment? captured = null; + _repositoryMock + .Setup(r => r.Add(It.IsAny())) + .Callback(pd => captured = pd); + + var cmd = CreateCommand(product, new Dictionary + { + ["DB_SERVER"] = "sqldev2017", + ["DB_NAME"] = "dev-amsproject" + }); + + await _handler.Handle(cmd, CancellationToken.None); + + captured.Should().NotBeNull(); + captured!.MaintenanceObserverConfig.Should().NotBeNull( + "the product-level observer must be wired onto the ProductDeployment or MaintenanceObserverService has nothing to poll"); + captured.MaintenanceObserverConfig!.Type.Value.Should().Be("sqlExtendedProperty"); + captured.MaintenanceObserverConfig.MaintenanceValue.Should().Be("1"); + } + + [Fact] + public async Task Handle_ObserverConnectionStringUnresolvable_LeavesConfigNull() + { + var product = CreateProductWithObserver( + new global::ReadyStackGo.Domain.StackManagement.Manifests.RsgoMaintenanceObserver + { + Type = "sqlExtendedProperty", + PropertyName = "ams-MaintenanceMode", + ConnectionString = "Server=${DB_SERVER};Database=${DB_NAME};", + MaintenanceValue = "1" + }); + + SetupProductFound(product); + SetupNoExistingDeployment(); + SetupAllStacksSucceed(); + + ProductDeployment? captured = null; + _repositoryMock + .Setup(r => r.Add(It.IsAny())) + .Callback(pd => captured = pd); + + // DB_NAME intentionally missing — mapper must refuse to produce a config. + var cmd = CreateCommand(product, new Dictionary + { + ["DB_SERVER"] = "sqldev2017" + }); + + await _handler.Handle(cmd, CancellationToken.None); + + captured.Should().NotBeNull(); + captured!.MaintenanceObserverConfig.Should().BeNull(); + } + + [Fact] + public async Task Handle_ProductHasNoObserver_LeavesConfigNull() + { + var product = CreateTestProduct(1); + SetupProductFound(product); + SetupNoExistingDeployment(); + SetupAllStacksSucceed(); + + ProductDeployment? captured = null; + _repositoryMock + .Setup(r => r.Add(It.IsAny())) + .Callback(pd => captured = pd); + + await _handler.Handle(CreateCommand(product), CancellationToken.None); + + captured.Should().NotBeNull(); + captured!.MaintenanceObserverConfig.Should().BeNull(); + } + + private static ProductDefinition CreateProductWithObserver( + global::ReadyStackGo.Domain.StackManagement.Manifests.RsgoMaintenanceObserver observer) + { + var baseProduct = CreateTestProduct(1); + return new ProductDefinition( + sourceId: "stacks", + name: baseProduct.Name, + displayName: baseProduct.DisplayName, + stacks: baseProduct.Stacks, + productVersion: baseProduct.ProductVersion, + maintenanceObserver: observer); + } + + #endregion } diff --git a/tests/ReadyStackGo.UnitTests/Application/Deployments/RedeployProductHandlerTests.cs b/tests/ReadyStackGo.UnitTests/Application/Deployments/RedeployProductHandlerTests.cs index c1c18790..90c38def 100644 --- a/tests/ReadyStackGo.UnitTests/Application/Deployments/RedeployProductHandlerTests.cs +++ b/tests/ReadyStackGo.UnitTests/Application/Deployments/RedeployProductHandlerTests.cs @@ -17,6 +17,7 @@ namespace ReadyStackGo.UnitTests.Application.Deployments; public class RedeployProductHandlerTests { private readonly Mock _repositoryMock; + private readonly Mock _productSourceServiceMock; private readonly Mock _mediatorMock; private readonly Mock _deploymentServiceMock; private readonly Mock> _loggerMock; @@ -28,6 +29,7 @@ public class RedeployProductHandlerTests public RedeployProductHandlerTests() { _repositoryMock = new Mock(); + _productSourceServiceMock = new Mock(); _mediatorMock = new Mock(); _deploymentServiceMock = new Mock(); _loggerMock = new Mock>(); @@ -45,6 +47,7 @@ public RedeployProductHandlerTests() _handler = new RedeployProductHandler( _repositoryMock.Object, + _productSourceServiceMock.Object, _mediatorMock.Object, _deploymentServiceMock.Object, _loggerMock.Object, @@ -343,4 +346,133 @@ public async Task Handle_GeneratesSessionId_WhenNotProvided() } #endregion + + #region Maintenance Observer Refresh + + [Fact] + public async Task Handle_ReResolvesMaintenanceObserverFromCatalog() + { + var pd = CreateRunningDeploymentWithSharedVars(new Dictionary + { + ["DB_SERVER"] = "sqldev2017", + ["DB_NAME"] = "dev-amsproject" + }); + SetupDeploymentFound(pd); + + _productSourceServiceMock + .Setup(s => s.GetProductAsync(pd.ProductId, It.IsAny())) + .ReturnsAsync(CreateCatalogProductWithObserver(pd.ProductId)); + + var command = CreateCommand(pd.Id.Value.ToString()); + await _handler.Handle(command, CancellationToken.None); + + pd.MaintenanceObserverConfig.Should().NotBeNull( + "redeploy must re-read maintenance observer from the catalog so stack.yaml edits take effect"); + pd.MaintenanceObserverConfig!.Type.Value.Should().Be("sqlExtendedProperty"); + } + + [Fact] + public async Task Handle_ObserverRemovedFromCatalog_ClearsConfig() + { + var pd = CreateRunningDeploymentWithSharedVars(new Dictionary()); + // Pre-seed an existing observer — simulating the old catalog state. + pd.SetMaintenanceObserverConfig(global::ReadyStackGo.Domain.Deployment.Observers.MaintenanceObserverConfig.Create( + global::ReadyStackGo.Domain.Deployment.Observers.ObserverType.File, + TimeSpan.FromSeconds(30), + "1", + "0", + global::ReadyStackGo.Domain.Deployment.Observers.FileObserverSettings.ForExistence("/tmp/x"))); + SetupDeploymentFound(pd); + + // Catalog returns product without observer. + _productSourceServiceMock + .Setup(s => s.GetProductAsync(pd.ProductId, It.IsAny())) + .ReturnsAsync(CreateCatalogProductWithoutObserver(pd.ProductId)); + + var command = CreateCommand(pd.Id.Value.ToString()); + await _handler.Handle(command, CancellationToken.None); + + pd.MaintenanceObserverConfig.Should().BeNull( + "observer removed from the catalog must also be removed from the ProductDeployment"); + } + + private static ProductDeployment CreateRunningDeploymentWithSharedVars(Dictionary sharedVars) + { + var pd = ProductDeployment.InitiateDeployment( + ProductDeploymentId.NewId(), EnvironmentId.NewId(), + "stacks:testproduct", "stacks:testproduct:1.0.0", + "testproduct", "Test Product", "1.0.0", + UserId.NewId(), "test-deploy", + CreateStackConfigs(1), + sharedVars); + + pd.StartStack("stack-0", DeploymentId.NewId()); + pd.CompleteStack("stack-0"); + return pd; + } + + private static global::ReadyStackGo.Domain.StackManagement.Stacks.ProductDefinition CreateCatalogProductWithObserver(string productId) + { + var stack = new global::ReadyStackGo.Domain.StackManagement.Stacks.StackDefinition( + "stacks", + "stack-0", + new global::ReadyStackGo.Domain.StackManagement.Stacks.ProductId(productId), + services: new[] + { + new global::ReadyStackGo.Domain.StackManagement.Stacks.ServiceTemplate + { + Name = "svc", Image = "test:latest" + } + }, + variables: Array.Empty(), + productName: "testproduct", + productDisplayName: "Test Product", + productVersion: "1.0.0"); + + return new global::ReadyStackGo.Domain.StackManagement.Stacks.ProductDefinition( + sourceId: "stacks", + name: "testproduct", + displayName: "Test Product", + stacks: new[] { stack }, + productVersion: "1.0.0", + maintenanceObserver: new global::ReadyStackGo.Domain.StackManagement.Manifests.RsgoMaintenanceObserver + { + Type = "sqlExtendedProperty", + PropertyName = "ams-MaintenanceMode", + ConnectionString = "Server=${DB_SERVER};Database=${DB_NAME};", + MaintenanceValue = "1", + NormalValue = "0", + PollingInterval = "30s" + }, + productId: productId); + } + + private static global::ReadyStackGo.Domain.StackManagement.Stacks.ProductDefinition CreateCatalogProductWithoutObserver(string productId) + { + var stack = new global::ReadyStackGo.Domain.StackManagement.Stacks.StackDefinition( + "stacks", + "stack-0", + new global::ReadyStackGo.Domain.StackManagement.Stacks.ProductId(productId), + services: new[] + { + new global::ReadyStackGo.Domain.StackManagement.Stacks.ServiceTemplate + { + Name = "svc", Image = "test:latest" + } + }, + variables: Array.Empty(), + productName: "testproduct", + productDisplayName: "Test Product", + productVersion: "1.0.0"); + + return new global::ReadyStackGo.Domain.StackManagement.Stacks.ProductDefinition( + sourceId: "stacks", + name: "testproduct", + displayName: "Test Product", + stacks: new[] { stack }, + productVersion: "1.0.0", + productId: productId); + } + + #endregion }