From 7cede9bc7d5a59e70434983746ec7c8a05149ee2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 8 Jun 2026 15:31:21 -0500 Subject: [PATCH 1/2] Support scoped worktree Aspire runs --- .../Extensions/WorktreeScope.cs | 125 ++++++++++++++ src/Exceptionless.AppHost/Program.cs | 118 +++++++++---- .../grunt/task-configs/connect.js | 4 + .../grunt/task-configs/watch.js | 4 +- .../ClientApp.angular/index.html | 3 - src/Exceptionless.Web/ClientApp/package.json | 1 + .../ClientApp/scripts/resolve-url.mjs | 157 ++++++++++++++++++ .../src/lib/features/status/models.ts | 1 + .../ClientApp/vite.config.ts | 7 +- .../Controllers/StatusController.cs | 1 + .../Exceptionless.Tests/AppWebHostFactory.cs | 35 ++-- .../Controllers/StatusControllerTests.cs | 17 +- 12 files changed, 408 insertions(+), 65 deletions(-) create mode 100644 src/Exceptionless.AppHost/Extensions/WorktreeScope.cs create mode 100644 src/Exceptionless.Web/ClientApp/scripts/resolve-url.mjs diff --git a/src/Exceptionless.AppHost/Extensions/WorktreeScope.cs b/src/Exceptionless.AppHost/Extensions/WorktreeScope.cs new file mode 100644 index 0000000000..0baeaf3ab9 --- /dev/null +++ b/src/Exceptionless.AppHost/Extensions/WorktreeScope.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +public sealed record WorktreePorts( + int DashboardHttps, + int DashboardHttp, + int DashboardOtlp, + int ResourceService, + int ApiHttp, + int ApiHttps, + int JobsHttp, + int OldAppHttp, + int OldAppHttps, + int OldAppLiveReload, + int AppHttps) +{ + public string ApiHttpUrl => $"http://localhost:{ApiHttp}"; + public string ApiHttpsUrl => $"https://localhost:{ApiHttps}"; + public string OldAppHttpsUrl => $"https://angular-ex.dev.localhost:{OldAppHttps}"; +} + +public static class WorktreeScope +{ + public static string? Resolve() + { + var explicitScope = Environment.GetEnvironmentVariable("Scope"); + if (!String.IsNullOrWhiteSpace(explicitScope)) + { + return Sanitize(explicitScope); + } + + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + { + var dotGit = Path.Combine(dir.FullName, ".git"); + if (File.Exists(dotGit)) + { + return Sanitize(ResolveGitWorktreeName(dotGit) ?? dir.Name); + } + + if (Directory.Exists(dotGit)) + { + return null; + } + } + + return null; + } + + public static WorktreePorts AssignFreePorts() + { + var ports = FreePorts(11); + var assignments = new WorktreePorts( + ports[0], + ports[1], + ports[2], + ports[3], + ports[4], + ports[5], + ports[6], + ports[7], + ports[8], + ports[9], + ports[10]); + + Environment.SetEnvironmentVariable("ASPNETCORE_URLS", $"https://localhost:{assignments.DashboardHttps};http://localhost:{assignments.DashboardHttp}"); + Environment.SetEnvironmentVariable("DOTNET_DASHBOARD_OTLP_ENDPOINT_URL", $"https://localhost:{assignments.DashboardOtlp}"); + Environment.SetEnvironmentVariable("DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", $"https://localhost:{assignments.ResourceService}"); + + return assignments; + } + + private static int[] FreePorts(int count) + { + var listeners = new List(count); + try + { + for (var i = 0; i < count; i++) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + listeners.Add(listener); + } + + return listeners.Select(l => ((IPEndPoint)l.LocalEndpoint).Port).ToArray(); + } + finally + { + foreach (var listener in listeners) + { + listener.Stop(); + } + } + } + + private static string? ResolveGitWorktreeName(string dotGitPath) + { + var content = File.ReadAllText(dotGitPath).Trim(); + const string gitDirPrefix = "gitdir:"; + if (!content.StartsWith(gitDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var gitDir = content[gitDirPrefix.Length..].Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return Path.GetFileName(gitDir); + } + + private static string Sanitize(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var ch in value.ToLowerInvariant()) + { + builder.Append(Char.IsLetterOrDigit(ch) ? ch : '-'); + } + + var cleaned = builder.ToString().Trim('-'); + while (cleaned.Contains("--", StringComparison.Ordinal)) + { + cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); + } + + return cleaned.Length > 40 ? cleaned[..40].Trim('-') : cleaned; + } +} diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index cd49b1a8a6..0da796a76e 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -2,25 +2,33 @@ using Aspire.Hosting.JavaScript; using Microsoft.Extensions.Hosting; +var scope = WorktreeScope.Resolve(); +var isScoped = !String.IsNullOrWhiteSpace(scope); +var worktreePorts = isScoped ? WorktreeScope.AssignFreePorts() : null; var builder = DistributedApplication.CreateBuilder(args); var servicesOnly = args.Any(arg => StringComparer.OrdinalIgnoreCase.Equals(arg, "--services-only") || StringComparer.OrdinalIgnoreCase.Equals(arg, "services-only")); +var oldAppHttpPort = worktreePorts?.OldAppHttp ?? 7120; +var oldAppPort = worktreePorts?.OldAppHttps ?? 7121; +var oldAppLiveReloadPort = worktreePorts?.OldAppLiveReload ?? 35729; +var oldAppAspNetCoreUrls = String.Concat("http://localhost:", oldAppHttpPort); +var appPort = worktreePorts?.AppHttps ?? 7131; +const string SharedEmailConnectionString = "smtp://localhost:1025"; var elastic = builder.AddElasticsearch("Elasticsearch", port: 9200) - .WithDataVolume(servicesOnly ? null : "exceptionless.data.v1"); + .WithDataVolume("exceptionless.data.v1") + .WithEndpointProxySupport(false); var storage = builder.AddAzureStorage("Storage") .RunAsEmulator(c => { + c.WithEndpointProxySupport(false); c.WithUrlForEndpoint("blob", u => { u.DisplayText = "Blobs"; u.DisplayLocation = UrlDisplayLocation.DetailsOnly; }); c.WithUrlForEndpoint("queue", u => { u.DisplayText = "Queues"; u.DisplayLocation = UrlDisplayLocation.DetailsOnly; }); c.WithUrlForEndpoint("table", u => { u.DisplayText = "Tables"; u.DisplayLocation = UrlDisplayLocation.DetailsOnly; }); - if (!servicesOnly) - { - c.WithLifetime(ContainerLifetime.Persistent); - c.WithContainerName("Exceptionless-Storage"); - c.WithDataVolume(); - } + c.WithLifetime(ContainerLifetime.Persistent); + c.WithContainerName("Exceptionless-Storage"); + c.WithDataVolume("exceptionless.storage.data.v1"); }); var storageBlobs = storage.AddBlobs("StorageBlobs"); @@ -28,6 +36,8 @@ var cache = builder.AddRedis("Redis", port: 6379) .WithImageTag("8.6") + .WithDataVolume("exceptionless.redis.data.v1") + .WithEndpointProxySupport(false) .WithClearCommand() .WithUrls(c => { @@ -39,41 +49,54 @@ var mail = builder.AddContainer("Mail", "axllent/mailpit") .WithImageTag("v1.27.10") + .WithEndpointProxySupport(false) .WithHttpEndpoint(8025, 8025, "http") .WithUrlForEndpoint("http", u => { u.DisplayText = "Mail"; u.DisplayOrder = 100; }) .WithHttpHealthCheck("/readyz") .WithEndpoint(1025, 1025) .WithUrlForEndpoint("tcp", u => u.DisplayLocation = UrlDisplayLocation.DetailsOnly); +var ownedElastic = elastic; +elastic = ownedElastic + .WithLifetime(ContainerLifetime.Persistent) + .WithContainerName("Exceptionless-Elasticsearch"); + if (!servicesOnly) { - elastic = elastic + elastic = elastic.WithKibana(b => b .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Elasticsearch") - .WithKibana(b => b - .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Kibana") - .WithParentRelationship(elastic)); + .WithEndpointProxySupport(false) + .WithContainerName("Exceptionless-Kibana") + .WithParentRelationship(ownedElastic)); +} - cache = cache - .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Redis") - .WithRedisInsight(b => b - .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-RedisInsight") - .WithUrlForEndpoint("http", u => u.DisplayText = "Redis") - .WithParentRelationship(cache), containerName: "Redis-insight"); - - mail = mail +var ownedCache = cache; +cache = ownedCache + .WithLifetime(ContainerLifetime.Persistent) + .WithContainerName("Exceptionless-Redis"); + +if (!servicesOnly) +{ + cache = cache.WithRedisInsight(b => b .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Mail"); + .WithEndpointProxySupport(false) + .WithContainerName("Exceptionless-RedisInsight") + .WithUrlForEndpoint("http", u => u.DisplayText = "Redis") + .WithParentRelationship(ownedCache), containerName: "Redis-insight"); +} + +mail = mail + .WithLifetime(ContainerLifetime.Persistent) + .WithContainerName("Exceptionless-Mail"); +if (!servicesOnly) +{ var api = builder.AddProject("Api") .WithReference(cache) .WithReference(elastic) .WithReference(storageBlobs, "AzureStorage") .WithReference(storageQueues, "AzureQueues") - .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") + .WithEnvironment("ConnectionStrings:Email", SharedEmailConnectionString) .WithEnvironment("RunJobsInProcess", "false") .WaitFor(elastic) .WaitFor(cache) @@ -83,12 +106,20 @@ .WithUrlForEndpoint("http", u => u.DisplayLocation = UrlDisplayLocation.DetailsOnly) .WithHttpHealthCheck("/health"); - builder.AddProject("Jobs", "AllJobs") + if (worktreePorts is not null) + { + api.WithEnvironment("Scope", scope!) + .WithEnvironment("AppScope", scope!) + .WithEndpoint("http", e => e.Port = worktreePorts.ApiHttp) + .WithEndpoint("https", e => e.Port = worktreePorts.ApiHttps); + } + + var jobs = builder.AddProject("Jobs", "AllJobs") .WithReference(cache) .WithReference(elastic) .WithReference(storageBlobs, "AzureStorage") .WithReference(storageQueues, "AzureQueues") - .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") + .WithEnvironment("ConnectionStrings:Email", SharedEmailConnectionString) .WaitFor(elastic) .WaitFor(cache) .WaitFor(mail) @@ -105,14 +136,22 @@ .WithHttpHealthCheck("/health") .WithParentRelationship(api); + if (worktreePorts is not null) + { + jobs.WithEnvironment("Scope", scope!) + .WithEnvironment("AppScope", scope!) + .WithEndpoint("http", e => e.Port = worktreePorts.JobsHttp); + } + #pragma warning disable ASPIREBROWSERLOGS001 var oldApp = builder.AddJavaScriptApp("OldApp", "../../src/Exceptionless.Web/ClientApp.angular", "serve") .WithBrowserLogs() .WithReference(api) .RemoveJavaScriptDebuggingAnnotation() - .WithEnvironment("ASPNETCORE_URLS", "http://localhost:7120") + .WithEnvironment("ASPNETCORE_URLS", oldAppAspNetCoreUrls) .WithEnvironment("USE_HTTPS", "true") - .WithHttpEndpoint(port: 7121, targetPort: 7121, name: "https", env: "PORT", isProxied: false) + .WithEnvironment("LIVERELOAD_PORT", oldAppLiveReloadPort.ToString()) + .WithHttpEndpoint(port: oldAppPort, targetPort: oldAppPort, name: "https", env: "PORT", isProxied: false) .WithEndpoint("https", e => { e.TargetHost = "angular-ex.dev.localhost"; @@ -125,19 +164,26 @@ u.DisplayOrder = 100; }) .WithParentRelationship(api); + + if (worktreePorts is not null) + { + oldApp.WithEnvironment("API_HTTP", worktreePorts.ApiHttpUrl) + .WithEnvironment("API_HTTPS", worktreePorts.ApiHttpsUrl); + } #pragma warning restore ASPIREBROWSERLOGS001 #pragma warning disable ASPIREBROWSERLOGS001 - builder.AddViteApp("App", "../Exceptionless.Web/ClientApp") + var app = builder.AddViteApp("App", "../Exceptionless.Web/ClientApp") .WithBrowserLogs() .WithReference(api) .WithReference(oldApp) .RemoveJavaScriptDebuggingAnnotation() + .WithEnvironment("PORT", appPort.ToString()) .WithEndpoint("http", e => { // 7131 (HTTPS via Aspire dev cert) instead of Vite's default 5173 to avoid clashing with other local Vite projects. - e.Port = 7131; - e.TargetPort = 7131; + e.Port = appPort; + e.TargetPort = appPort; e.TargetHost = "web-ex.dev.localhost"; e.IsProxied = false; }) @@ -149,6 +195,14 @@ u.Url = $"{u.Url.TrimEnd('/')}/next/"; }) .WithParentRelationship(api); + + if (worktreePorts is not null) + { + app.WithEnvironment("API_HTTP", worktreePorts.ApiHttpUrl) + .WithEnvironment("API_HTTPS", worktreePorts.ApiHttpsUrl) + .WithEnvironment("OLDAPP_HTTP", worktreePorts.OldAppHttpsUrl) + .WithEnvironment("OLDAPP_HTTPS", worktreePorts.OldAppHttpsUrl); + } #pragma warning restore ASPIREBROWSERLOGS001 } diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js index a4a6621fde..0a3dfc3006 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js @@ -2,12 +2,15 @@ var path = require("path"); var fs = require("fs"); var s = require("child_process"); // eslint-disable-next-line import/no-extraneous-dependencies +var livereload = require("connect-livereload"); +// eslint-disable-next-line import/no-extraneous-dependencies var proxyRequest = require("grunt-connect-proxy2/lib/utils").proxyRequest; module.exports = function () { var target = getTarget(); var useHttps = String(process.env.USE_HTTPS || "").toLowerCase() === "true"; var port = Number(process.env.PORT) || 7121; + var liveReloadPort = Number(process.env.LIVERELOAD_PORT) || 35729; var certs = useHttps ? generateCerts() : { cert: undefined, key: undefined }; return { @@ -19,6 +22,7 @@ module.exports = function () { cert: certs.cert, middleware: function (connect, options, middlewares) { middlewares.unshift(proxyRequest); + middlewares.splice(1, 0, livereload({ port: liveReloadPort })); return middlewares; }, }, diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js index 766648cc5f..722d21ef9b 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js @@ -1,8 +1,10 @@ module.exports = function (grunt) { + var liveReloadPort = Number(process.env.LIVERELOAD_PORT) || 35729; + return { main: { options: { - livereload: true, + livereload: liveReloadPort, livereloadOnError: false, spawn: false, }, diff --git a/src/Exceptionless.Web/ClientApp.angular/index.html b/src/Exceptionless.Web/ClientApp.angular/index.html index 4326a7b212..a65c9f7172 100644 --- a/src/Exceptionless.Web/ClientApp.angular/index.html +++ b/src/Exceptionless.Web/ClientApp.angular/index.html @@ -83,9 +83,6 @@ data-remove="true" > - - -