From 21320080085c21ad5bfde67e85317ec550826e02 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jul 2025 02:13:33 -0700 Subject: [PATCH 1/3] Made changes to how parameter.Value works - Value now blocks (sync over async) waiting for value resolution if WaitForValueTcs is set. - Made GetValueAsync on ParameterResource public. - Changed most code outside of tests to use GetValueAsync instead of Value --- .../Provisioners/BicepProvisioner.cs | 2 +- .../MySqlBuilderExtensions.cs | 16 ++++----- .../NatsBuilderExtensions.cs | 2 +- .../PostgresBuilderExtensions.cs | 36 +++++++++++-------- .../RedisBuilderExtensions.cs | 11 +++--- .../ApplicationModel/ParameterResource.cs | 14 ++++++-- .../ExternalServiceBuilderExtensions.cs | 7 +++- .../Orchestrator/ParameterProcessor.cs | 2 +- .../ResourceBuilderExtensions.cs | 11 +++--- 9 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index e8909737d64..5d84aa3ad43 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -106,7 +106,7 @@ public async Task GetOrCreateResourceAsync(AzureBicepResource resource, Provisio if (BicepUtilities.GetExistingResourceGroup(resource) is { } existingResourceGroup) { var existingResourceGroupName = existingResourceGroup is ParameterResource parameterResource - ? parameterResource.Value + ? (await parameterResource.GetValueAsync(cancellationToken).ConfigureAwait(false))! : (string)existingResourceGroup; var response = await context.Subscription.GetResourceGroups().GetAsync(existingResourceGroupName, cancellationToken).ConfigureAwait(false); resourceGroup = response.Value; diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index cc3f1f072c5..c5d20cc2935 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -205,14 +205,14 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui .WithHttpEndpoint(targetPort: 80, name: "http") .ExcludeFromManifest(); - builder.ApplicationBuilder.Eventing.Subscribe(phpMyAdminContainer, (e, ct) => + builder.ApplicationBuilder.Eventing.Subscribe(phpMyAdminContainer, async (e, ct) => { var mySqlInstances = builder.ApplicationBuilder.Resources.OfType(); if (!mySqlInstances.Any()) { // No-op if there are no MySql resources present. - return Task.CompletedTask; + return; } if (mySqlInstances.Count() == 1) @@ -225,12 +225,12 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui // This will need to be refactored once updated service discovery APIs are available context.EnvironmentVariables.Add("PMA_HOST", $"{endpoint.Resource.Name}:{endpoint.TargetPort}"); context.EnvironmentVariables.Add("PMA_USER", "root"); - context.EnvironmentVariables.Add("PMA_PASSWORD", singleInstance.PasswordParameter.Value); + context.EnvironmentVariables.Add("PMA_PASSWORD", singleInstance.PasswordParameter); }); } else { - var tempConfigFile = WritePhpMyAdminConfiguration(mySqlInstances); + var tempConfigFile = await WritePhpMyAdminConfiguration(mySqlInstances, ct).ConfigureAwait(false); try { @@ -258,8 +258,6 @@ public static IResourceBuilder WithPhpMyAdmin(this IResourceBuilder bui } } } - - return Task.CompletedTask; }); configureContainer?.Invoke(phpMyAdminContainerBuilder); @@ -346,7 +344,7 @@ public static IResourceBuilder WithInitFiles(this IResource return builder.WithContainerFiles(initPath, importFullPath); } - private static string WritePhpMyAdminConfiguration(IEnumerable mySqlInstances) + private static async Task WritePhpMyAdminConfiguration(IEnumerable mySqlInstances, CancellationToken cancellationToken) { // This temporary file is not used by the container, it will be copied and then deleted var filePath = Path.GetTempFileName(); @@ -360,6 +358,8 @@ private static string WritePhpMyAdminConfiguration(IEnumerable AddNats(this IDistributedAppl AuthOpts = new() { Username = await nats.UserNameReference.GetValueAsync(ct).ConfigureAwait(false), - Password = nats.PasswordParameter!.Value, + Password = await nats.PasswordParameter!.GetValueAsync(ct).ConfigureAwait(false), } }; diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 3e092fe43a4..ffb79e3b7cb 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -203,18 +203,18 @@ public static IResourceBuilder WithPgAdmin(this IResourceBuilder builde pgAdminContainerBuilder.WithContainerFiles( destinationPath: "/pgadmin4", - callback: (context, _) => + callback: async (context, cancellationToken) => { var appModel = context.ServiceProvider.GetRequiredService(); var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); - return Task.FromResult>([ + return [ new ContainerFile { Name = "servers.json", - Contents = WritePgAdminServerJson(postgresInstances), + Contents = await WritePgAdminServerJson(postgresInstances, cancellationToken).ConfigureAwait(false), }, - ]); + ]; }); configureContainer?.Invoke(pgAdminContainerBuilder); @@ -313,13 +313,13 @@ public static IResourceBuilder WithPgWeb(this IResourceB pgwebContainerBuilder.WithContainerFiles( destinationPath: "/", - callback: (context, _) => + callback: async (context, ct) => { var appModel = context.ServiceProvider.GetRequiredService(); var postgresInstances = builder.ApplicationBuilder.Resources.OfType(); // Add the bookmarks to the pgweb container - return Task.FromResult>([ + return [ new ContainerDirectory { Name = ".pgweb", @@ -327,11 +327,11 @@ public static IResourceBuilder WithPgWeb(this IResourceB new ContainerDirectory { Name = "bookmarks", - Entries = WritePgWebBookmarks(postgresInstances), + Entries = await WritePgWebBookmarks(postgresInstances, ct).ConfigureAwait(false) }, ], }, - ]); + ]; }); return builder; @@ -489,13 +489,17 @@ public static IResourceBuilder WithHostPort(this IResour }); } - private static IEnumerable WritePgWebBookmarks(IEnumerable postgresInstances) + private static async Task> WritePgWebBookmarks(IEnumerable postgresInstances, CancellationToken cancellationToken) { var bookmarkFiles = new List(); foreach (var postgresDatabase in postgresInstances) { - var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres"; + var user = postgresDatabase.Parent.UserNameParameter is null + ? "postgres" + : await postgresDatabase.Parent.UserNameParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + + var password = await postgresDatabase.Parent.PasswordParameter.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? "password"; // PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address // This will need to be refactored once updated service discovery APIs are available @@ -503,7 +507,7 @@ private static IEnumerable WritePgWebBookmarks(IEnumera host = "{postgresDatabase.Parent.Name}" port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort} user = "{user}" - password = "{postgresDatabase.Parent.PasswordParameter.Value}" + password = "{password}" database = "{postgresDatabase.DatabaseName}" sslmode = "disable" """; @@ -518,7 +522,7 @@ private static IEnumerable WritePgWebBookmarks(IEnumera return bookmarkFiles; } - private static string WritePgAdminServerJson(IEnumerable postgresInstances) + private static async Task WritePgAdminServerJson(IEnumerable postgresInstances, CancellationToken cancellationToken) { using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); @@ -531,6 +535,10 @@ private static string WritePgAdminServerJson(IEnumerable foreach (var postgresInstance in postgresInstances) { var endpoint = postgresInstance.PrimaryEndpoint; + var userName = postgresInstance.UserNameParameter is null + ? "postgres" + : await postgresInstance.UserNameParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + var password = await postgresInstance.PasswordParameter.GetValueAsync(cancellationToken).ConfigureAwait(false); writer.WriteStartObject($"{serverIndex}"); writer.WriteString("Name", postgresInstance.Name); @@ -539,10 +547,10 @@ private static string WritePgAdminServerJson(IEnumerable // This will need to be refactored once updated service discovery APIs are available writer.WriteString("Host", endpoint.Resource.Name); writer.WriteNumber("Port", (int)endpoint.TargetPort!); - writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres"); + writer.WriteString("Username", userName); writer.WriteString("SSLMode", "prefer"); writer.WriteString("MaintenanceDB", "postgres"); - writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful. + writer.WriteString("PasswordExecCommand", $"echo '{password}'"); // HACK: Generating a pass file and playing around with chmod is too painful. writer.WriteEndObject(); serverIndex++; diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 378bd3a1e31..e3666d40894 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -158,14 +158,14 @@ public static IResourceBuilder WithRedisCommander(this IResourceB .WithHttpEndpoint(targetPort: 8081, name: "http") .ExcludeFromManifest(); - builder.ApplicationBuilder.Eventing.Subscribe(resource, (e, ct) => + builder.ApplicationBuilder.Eventing.Subscribe(resource, async (e, ct) => { var redisInstances = builder.ApplicationBuilder.Resources.OfType(); if (!redisInstances.Any()) { // No-op if there are no Redis resources present. - return Task.CompletedTask; + return; } var hostsVariableBuilder = new StringBuilder(); @@ -177,14 +177,13 @@ public static IResourceBuilder WithRedisCommander(this IResourceB var hostString = $"{(hostsVariableBuilder.Length > 0 ? "," : string.Empty)}{redisInstance.Name}:{redisInstance.Name}:{redisInstance.PrimaryEndpoint.TargetPort}:0"; if (redisInstance.PasswordParameter is not null) { - hostString += $":{redisInstance.PasswordParameter.Value}"; + var password = await redisInstance.PasswordParameter.GetValueAsync(ct).ConfigureAwait(false); + hostString += $":{password}"; } hostsVariableBuilder.Append(hostString); } resourceBuilder.WithEnvironment("REDIS_HOSTS", hostsVariableBuilder.ToString()); - - return Task.CompletedTask; }); configureContainer?.Invoke(resourceBuilder); @@ -244,7 +243,7 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); if (redisInstance.PasswordParameter is not null) { - context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); + context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter); } counter++; diff --git a/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs b/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs index 6ca4260b80f..662d0d8e780 100644 --- a/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs @@ -31,7 +31,9 @@ public ParameterResource(string name, Func callback, /// /// Gets the value of the parameter. /// - public string Value + public string Value => GetValueAsync(default).AsTask().GetAwaiter().GetResult()!; + + internal string ValueInternal { get { @@ -79,7 +81,12 @@ internal string ConfigurationKey /// internal TaskCompletionSource? WaitForValueTcs { get; set; } - async ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) + /// + /// Gets the value of the parameter asynchronously, waiting if necessary for the value to be set. + /// + /// The cancellation token to observe while waiting for the value. + /// A task that represents the asynchronous operation, containing the value of the parameter. + public async ValueTask GetValueAsync(CancellationToken cancellationToken) { if (WaitForValueTcs is not null) { @@ -87,6 +94,7 @@ internal string ConfigurationKey return await WaitForValueTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } - return Value; + // In publish mode, there's no WaitForValueTcs set. + return ValueInternal; } } diff --git a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs index f7ba38dbacf..a112738fd75 100644 --- a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs @@ -80,6 +80,7 @@ private static IResourceBuilder AddExternalServiceImpl( .WithInitialState(new CustomResourceSnapshot { ResourceType = "ExternalService", + State = KnownResourceStates.Waiting, Properties = [] }) .ExcludeFromManifest(); @@ -104,7 +105,9 @@ private static IResourceBuilder AddExternalServiceImpl( if (uri is null) { // If the URI is not set, it means we are using a parameterized URL - var url = resource.UrlParameter?.Value; + var url = resource.UrlParameter is null + ? null + : await resource.UrlParameter.GetValueAsync(ct).ConfigureAwait(false); if (!ExternalServiceResource.UrlIsValidForExternalService(url, out uri, out var message)) { @@ -182,6 +185,8 @@ public static IResourceBuilder WithHttpHealthCheck(this { var uri = builder.Resource.Uri; + // OK accessing the paramter here synchronously as this should only activate once the resource is running + if (uri is null && !Uri.TryCreate(builder.Resource.UrlParameter?.Value, UriKind.Absolute, out uri) || (uri?.Scheme != "http" && uri?.Scheme != "https")) { diff --git a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs index 40550fe85ba..afd6158460c 100644 --- a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs +++ b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs @@ -62,7 +62,7 @@ private async Task ProcessParameterAsync(ParameterResource parameterResource) { try { - var value = parameterResource.Value ?? ""; + var value = parameterResource.ValueInternal ?? ""; await notificationService.PublishUpdateAsync(parameterResource, s => { diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 87436cd5a46..364d0c793c9 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -177,13 +177,16 @@ public static IResourceBuilder WithEnvironment(this IResourceBuilder bu } else if (externalService.Resource.UrlParameter is not null) { - builder.WithEnvironment(context => + builder.WithEnvironment(async context => { + var url = await externalService.Resource.UrlParameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + // In publish mode we can't validate the parameter value so we'll just use it without validating. - if (!context.ExecutionContext.IsPublishMode && !ExternalServiceResource.UrlIsValidForExternalService(externalService.Resource.UrlParameter.Value, out var _, out var message)) + if (!context.ExecutionContext.IsPublishMode && !ExternalServiceResource.UrlIsValidForExternalService(url, out var _, out var message)) { throw new DistributedApplicationException($"The URL parameter '{externalService.Resource.UrlParameter.Name}' for the external service '{externalService.Resource.Name}' is invalid: {message}"); } + context.EnvironmentVariables[name] = externalService.Resource.UrlParameter; }); } @@ -528,7 +531,7 @@ public static IResourceBuilder WithReference(this IR } else if (externalService.Resource.UrlParameter is not null) { - builder.WithEnvironment(context => + builder.WithEnvironment(async context => { string envVarName; if (context.ExecutionContext.IsPublishMode) @@ -536,7 +539,7 @@ public static IResourceBuilder WithReference(this IR // In publish mode we can't read the parameter value to get the scheme so use 'default' envVarName = $"services__{externalService.Resource.Name}__default__0"; } - else if (ExternalServiceResource.UrlIsValidForExternalService(externalService.Resource.UrlParameter.Value, out var uri, out var message)) + else if (ExternalServiceResource.UrlIsValidForExternalService(await externalService.Resource.UrlParameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false), out var uri, out var message)) { envVarName = $"services__{externalService.Resource.Name}__{uri.Scheme}__0"; } From 00e739b285f038c3c485b6a526299196bcdbc36d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jul 2025 02:22:29 -0700 Subject: [PATCH 2/3] Fix typo in comment regarding parameter access in WithHttpHealthCheck method --- src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs index a112738fd75..0ca89a028a9 100644 --- a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs @@ -185,7 +185,7 @@ public static IResourceBuilder WithHttpHealthCheck(this { var uri = builder.Resource.Uri; - // OK accessing the paramter here synchronously as this should only activate once the resource is running + // OK accessing the parameter here synchronously as this should only activate once the resource is running if (uri is null && !Uri.TryCreate(builder.Resource.UrlParameter?.Value, UriKind.Absolute, out uri) || (uri?.Scheme != "http" && uri?.Scheme != "https")) From 6b6aba2160d7202435a435d484809bac6c118bb4 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jul 2025 10:09:18 -0700 Subject: [PATCH 3/3] Add error handling for URL parameter resolution in external service --- .../ExternalServiceBuilderExtensions.cs | 21 ++++- .../ExternalServiceTests.cs | 81 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs index 0ca89a028a9..a2c50076842 100644 --- a/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs @@ -105,9 +105,24 @@ private static IResourceBuilder AddExternalServiceImpl( if (uri is null) { // If the URI is not set, it means we are using a parameterized URL - var url = resource.UrlParameter is null - ? null - : await resource.UrlParameter.GetValueAsync(ct).ConfigureAwait(false); + string? url; + try + { + url = resource.UrlParameter is null + ? null + : await resource.UrlParameter.GetValueAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + e.Logger.LogError(ex, "Failed to get value for URL parameter '{ParameterName}'", resource.UrlParameter?.Name); + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }).ConfigureAwait(false); + + return; + } if (!ExternalServiceResource.UrlIsValidForExternalService(url, out uri, out var message)) { diff --git a/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs b/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs index 0e8c40259da..8bd3dade85b 100644 --- a/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ExternalServiceTests.cs @@ -289,6 +289,87 @@ public void ExternalServiceUrlValidationHelper() Assert.Contains("absolute path must be \"/\"", pathMessage); } + [Fact] + public async Task ExternalServiceWithParameterGetValueAsyncErrorMarksAsFailedToStart() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Create a parameter with a broken value callback + var urlParam = builder.AddParameter("failing-url", () => throw new InvalidOperationException("Parameter resolution failed")); + var externalService = builder.AddExternalService("external", urlParam); + + using var app = builder.Build(); + + // Start the app to trigger InitializeResourceEvent + var appStartTask = app.StartAsync(); + + // Wait for the resource to be marked as FailedToStart + var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + externalService.Resource.Name, + e => e.Snapshot.State?.Text == KnownResourceStates.FailedToStart + ).DefaultTimeout(); + + // Verify the resource is in the correct state + Assert.Equal(KnownResourceStates.FailedToStart, resourceEvent.Snapshot.State?.Text); + + await app.StopAsync(); + await appStartTask; // Ensure start completes + } + + [Fact] + public async Task ExternalServiceWithParameterInvalidUrlMarksAsFailedToStart() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Create a parameter that returns an invalid URL + var urlParam = builder.AddParameter("invalid-url", () => "invalid-url-not-absolute"); + var externalService = builder.AddExternalService("external", urlParam); + + using var app = builder.Build(); + + // Start the app to trigger InitializeResourceEvent + var appStartTask = app.StartAsync(); + + // Wait for the resource to be marked as FailedToStart + var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + externalService.Resource.Name, + e => e.Snapshot.State?.Text == KnownResourceStates.FailedToStart + ).DefaultTimeout(); + + // Verify the resource is in the correct state + Assert.Equal(KnownResourceStates.FailedToStart, resourceEvent.Snapshot.State?.Text); + + await app.StopAsync(); + await appStartTask; // Ensure start completes + } + + [Fact] + public async Task ExternalServiceWithValidParameterMarksAsRunning() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Create a parameter that returns a valid URL + var urlParam = builder.AddParameter("valid-url", () => "https://example.com/"); + var externalService = builder.AddExternalService("external", urlParam); + + using var app = builder.Build(); + + // Start the app to trigger InitializeResourceEvent + var appStartTask = app.StartAsync(); + + // Wait for the resource to be marked as Running + var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + externalService.Resource.Name, + e => e.Snapshot.State?.Text == KnownResourceStates.Running + ).DefaultTimeout(); + + // Verify the resource is in the correct state + Assert.Equal(KnownResourceStates.Running, resourceEvent.Snapshot.State?.Text); + + await app.StopAsync(); + await appStartTask; // Ensure start completes + } + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "testproject";