diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 934367675c..dd664c3b02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 8.0.x + global-json-file: global.json - name: Set up Node.js uses: actions/setup-node@v6.2.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dc363ea1d..d2ef90876f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 7.0.x + global-json-file: global.json - name: Set up Node.js uses: actions/setup-node@v6.2.0 with: @@ -82,7 +82,7 @@ jobs: $nugetsCount = (Get-ChildItem -Recurse -File nugets).Count $expectedAssetsCount = 1 - $expectedNugetsCount = 1 + $expectedNugetsCount = 2 if ($assetsCount -ne $expectedAssetsCount) { @@ -95,6 +95,9 @@ jobs: Write-Host Nugets: Expected $expectedNugetsCount but found $nugetsCount exit -1 } + - name: Push packages to testing feed + if: ${{ github.event_name == 'workflow_dispatch' }} + run: dotnet nuget push nugets\*.nupkg --api-key ${{ secrets.FEEDZIO_PUBLISH_API_KEY }} --source "${{ vars.PARTICULAR_TESTING_FEED_URL }}" # Deploy to Octopus - name: Deploy if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} @@ -170,7 +173,7 @@ jobs: org.opencontainers.image.created=${{ steps.date.outputs.date }} org.opencontainers.image.title=ServicePulse org.opencontainers.image.description=ServicePulse provides real-time production monitoring for distributed applications. It monitors the health of a system's endpoints, detects processing errors, sends failed messages for reprocessing, and ensures the specific environment's needs are met, all in one consolidated dashboard. - org.opencontainers.image.base.name=mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled-composite + org.opencontainers.image.base.name=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-composite annotations: | index:org.opencontainers.image.source=https://github.com/Particular/ServicePulse/tree/${{ github.sha }} index:org.opencontainers.image.authors="Particular Software" @@ -182,7 +185,7 @@ jobs: index:org.opencontainers.image.created=${{ steps.date.outputs.date }} index:org.opencontainers.image.title=ServicePulse index:org.opencontainers.image.description=ServicePulse provides real-time production monitoring for distributed applications. It monitors the health of a system's endpoints, detects processing errors, sends failed messages for reprocessing, and ensures the specific environment's needs are met, all in one consolidated dashboard. - index:org.opencontainers.image.base.name=mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled-composite + index:org.opencontainers.image.base.name=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-composite file: src/ServicePulse/Dockerfile tags: ghcr.io/particular/servicepulse:${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || env.MinVerVersion }} diff --git a/global.json b/global.json index 2134ed947e..506dcae16d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { - "version": "8.0.400", + "version": "10.0.0", + "allowPrerelease": false, "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/src/Frontend/env.d.ts b/src/Frontend/env.d.ts index 25bf2dc338..11ae739bab 100644 --- a/src/Frontend/env.d.ts +++ b/src/Frontend/env.d.ts @@ -10,6 +10,7 @@ declare global { service_control_url: string; monitoring_urls: string[]; showPendingRetry: boolean; + isIntegrated?: boolean; }; } } diff --git a/src/Frontend/public/js/app.constants.js b/src/Frontend/public/js/app.constants.js index d138f99109..1c1a6c9e2f 100644 --- a/src/Frontend/public/js/app.constants.js +++ b/src/Frontend/public/js/app.constants.js @@ -4,4 +4,5 @@ window.defaultConfig = { service_control_url: 'http://localhost:33333/api/', monitoring_urls: ['http://localhost:33633/'], showPendingRetry: false, + isIntegrated: false, }; diff --git a/src/Frontend/src/components/PageFooter.vue b/src/Frontend/src/components/PageFooter.vue index ecb826af4f..976bd36c2d 100644 --- a/src/Frontend/src/components/PageFooter.vue +++ b/src/Frontend/src/components/PageFooter.vue @@ -21,6 +21,7 @@ const environment = environmentAndVersionsStore.environment; const licenseStore = useLicenseStore(); const { licenseStatus, license } = licenseStore; const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; +const isIntegrated = window.defaultConfig.isIntegrated; const scAddressTooltip = computed(() => { return `ServiceControl URL ${serviceControlClient.url}`; @@ -44,11 +45,14 @@ const { configuration } = storeToRefs(configurationStore); Connect new endpoint - ServicePulse v{{ environment.sp_version }} - - ServicePulse v{{ environment.sp_version }} ( - v{{ newVersions.newSPVersion.newspversionnumber }} available) - + Integrated ServicePulse + Service Control: diff --git a/src/Frontend/src/components/configuration/PlatformConnections.vue b/src/Frontend/src/components/configuration/PlatformConnections.vue index 2f97ee54ea..75e86aeda3 100644 --- a/src/Frontend/src/components/configuration/PlatformConnections.vue +++ b/src/Frontend/src/components/configuration/PlatformConnections.vue @@ -20,6 +20,7 @@ const serviceControlValid = ref(null); const testingMonitoring = ref(false); const monitoringValid = ref(null); const connectionSaved = ref(null); +const isIntegrated = window.defaultConfig.isIntegrated; async function testServiceControlUrl() { if (localServiceControlUrl.value) { @@ -64,10 +65,15 @@ function saveConnections() { } function updateServiceControlUrls() { - if (!localServiceControlUrl.value) { - throw new Error("ServiceControl URL is mandatory"); - } else if (!localServiceControlUrl.value.endsWith("/")) { - localServiceControlUrl.value += "/"; + const params = new URLSearchParams(); + + if (!isIntegrated) { + if (!localServiceControlUrl.value) { + throw new Error("ServiceControl URL is mandatory"); + } else if (!localServiceControlUrl.value.endsWith("/")) { + localServiceControlUrl.value += "/"; + } + params.set("scu", localServiceControlUrl.value); } if (!localMonitoringUrl.value) { @@ -76,8 +82,6 @@ function updateServiceControlUrls() { localMonitoringUrl.value += "/"; } - const params = new URLSearchParams(); - params.set("scu", localServiceControlUrl.value); params.set("mu", localMonitoringUrl.value); window.location.search = `?${params.toString()}`; } @@ -94,11 +98,14 @@ function updateServiceControlUrls() {
- +
diff --git a/src/Frontend/src/components/serviceControlClient.ts b/src/Frontend/src/components/serviceControlClient.ts index a5d025b9dd..73e3c0c9e0 100644 --- a/src/Frontend/src/components/serviceControlClient.ts +++ b/src/Frontend/src/components/serviceControlClient.ts @@ -96,6 +96,10 @@ class ServiceControlClient { } private getUrl() { + if (window.defaultConfig?.isIntegrated && window.defaultConfig.service_control_url?.length) { + return window.defaultConfig.service_control_url; + } + const searchParams = new URLSearchParams(window.location.search); const scu = searchParams.get("scu"); const existingScu = window.localStorage.getItem("scu"); diff --git a/src/Frontend/test/specs/monitoring/sorting-endpoints.spec.ts b/src/Frontend/test/specs/monitoring/sorting-endpoints.spec.ts index 4a636d9231..68841efc34 100644 --- a/src/Frontend/test/specs/monitoring/sorting-endpoints.spec.ts +++ b/src/Frontend/test/specs/monitoring/sorting-endpoints.spec.ts @@ -1,5 +1,6 @@ import { expect } from "vitest"; import { test, describe } from "../../drivers/vitest/driver"; +import { waitFor } from "@testing-library/vue"; import { groupEndpointsBy } from "./actions/groupEndpointsBy"; import { endpointGroupNames } from "./questions/endpointGroupNames"; import { endpointGroup } from "./questions/endpointGroup"; @@ -68,10 +69,12 @@ describe("FEATURE: Endpoint sorting", () => { await sortEndpointsBy({ column: columnName.ENDPOINTNAME }); //Assert - expect(endpointGroupNames()).toEqual(["Universe.Solarsystem.Venus", "Universe.Solarsystem.Mercury", "Universe.Solarsystem.Earth"]); - expect(endpointGroup("Universe.Solarsystem.Venus").Endpoints).toEqual(["Endpoint4", "Endpoint3"]); - expect(endpointGroup("Universe.Solarsystem.Mercury").Endpoints).toEqual(["Endpoint2", "Endpoint1"]); - expect(endpointGroup("Universe.Solarsystem.Earth").Endpoints).toEqual(["Endpoint6", "Endpoint5"]); + await waitFor(() => { + expect(endpointGroupNames()).toEqual(["Universe.Solarsystem.Venus", "Universe.Solarsystem.Mercury", "Universe.Solarsystem.Earth"]); + expect(endpointGroup("Universe.Solarsystem.Venus").Endpoints).toEqual(["Endpoint4", "Endpoint3"]); + expect(endpointGroup("Universe.Solarsystem.Mercury").Endpoints).toEqual(["Endpoint2", "Endpoint1"]); + expect(endpointGroup("Universe.Solarsystem.Earth").Endpoints).toEqual(["Endpoint6", "Endpoint5"]); + }); }); test("EXAMPLE: Endpoints inside of the groups and group names should be sorted in ascending order when clicking twice on the endpoint name column title", async ({ driver }) => { @@ -95,10 +98,12 @@ describe("FEATURE: Endpoint sorting", () => { await sortEndpointsBy({ column: columnName.ENDPOINTNAME }); //Click the column title again for ascending //Assert - expect(endpointGroupNames()).toEqual(["Universe.Solarsystem.Earth", "Universe.Solarsystem.Mercury", "Universe.Solarsystem.Venus"]); - expect(endpointGroup("Universe.Solarsystem.Earth").Endpoints).toEqual(["Endpoint5", "Endpoint6"]); - expect(endpointGroup("Universe.Solarsystem.Mercury").Endpoints).toEqual(["Endpoint1", "Endpoint2"]); - expect(endpointGroup("Universe.Solarsystem.Venus").Endpoints).toEqual(["Endpoint3", "Endpoint4"]); + await waitFor(() => { + expect(endpointGroupNames()).toEqual(["Universe.Solarsystem.Earth", "Universe.Solarsystem.Mercury", "Universe.Solarsystem.Venus"]); + expect(endpointGroup("Universe.Solarsystem.Earth").Endpoints).toEqual(["Endpoint5", "Endpoint6"]); + expect(endpointGroup("Universe.Solarsystem.Mercury").Endpoints).toEqual(["Endpoint1", "Endpoint2"]); + expect(endpointGroup("Universe.Solarsystem.Venus").Endpoints).toEqual(["Endpoint3", "Endpoint4"]); + }); }); }); diff --git a/src/Particular.PlatformSample.ServicePulse/Particular.PlatformSample.ServicePulse.csproj b/src/Particular.PlatformSample.ServicePulse/Particular.PlatformSample.ServicePulse.csproj index ce2d8ddf1c..2ada072576 100644 --- a/src/Particular.PlatformSample.ServicePulse/Particular.PlatformSample.ServicePulse.csproj +++ b/src/Particular.PlatformSample.ServicePulse/Particular.PlatformSample.ServicePulse.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false true Particular ServicePulse binaries for use by Particular.PlatformSample. Not intended for use outside of Particular.PlatformSample. diff --git a/src/Particular.ServicePulse.Core/ConstantsFile.cs b/src/Particular.ServicePulse.Core/ConstantsFile.cs new file mode 100644 index 0000000000..d790f0a6de --- /dev/null +++ b/src/Particular.ServicePulse.Core/ConstantsFile.cs @@ -0,0 +1,30 @@ +namespace ServicePulse; + +using System.Reflection; + +class ConstantsFile +{ + public static string GetContent(ServicePulseSettings settings) + { + var version = GetVersionInformation(); + + var constantsFile = $$""" +window.defaultConfig = { + default_route: '{{settings.DefaultRoute}}', + version: '{{version}}', + service_control_url: '{{settings.ServiceControlUrl}}', + monitoring_urls: ['{{settings.MonitoringUrl ?? "!"}}'], + showPendingRetry: {{settings.ShowPendingRetry.ToString().ToLower()}}, + isIntegrated: {{settings.IsIntegrated.ToString().ToLower()}} +} +"""; + + return constantsFile; + } + + static string GetVersionInformation() + => typeof(ConstantsFile).Assembly + .GetCustomAttributes() + .SingleOrDefault(attribute => attribute.Key == "MajorMinorPatch") + ?.Value ?? "0.0.0"; +} diff --git a/src/Particular.ServicePulse.Core/Particular.ServicePulse.Core.csproj b/src/Particular.ServicePulse.Core/Particular.ServicePulse.Core.csproj new file mode 100644 index 0000000000..8aaa0d525d --- /dev/null +++ b/src/Particular.ServicePulse.Core/Particular.ServicePulse.Core.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + Particular ServicePulse binaries for use by integrated ServicePulse. Not intended for use outside of ServiceControl. + true + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Particular.ServicePulse.Core/ServicePulseHostingExtensions.cs b/src/Particular.ServicePulse.Core/ServicePulseHostingExtensions.cs new file mode 100644 index 0000000000..3e65383cdc --- /dev/null +++ b/src/Particular.ServicePulse.Core/ServicePulseHostingExtensions.cs @@ -0,0 +1,37 @@ +namespace ServicePulse; + +using System.Net.Mime; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; + +/// +/// Extensions for hosting ServicePulse within a WebApplication. +/// +public static class ServicePulseHostingExtensions +{ + /// + /// Adds ServicePulse static file serving and configuration endpoint to the WebApplication. + /// + public static void UseServicePulse(this WebApplication app, ServicePulseSettings settings, IFileProvider? overrideFileProvider = null) + { + var manifestEmbeddedFileProvider = new ManifestEmbeddedFileProvider(typeof(ServicePulseHostingExtensions).Assembly, "wwwroot"); + IFileProvider fileProvider = overrideFileProvider is null + ? manifestEmbeddedFileProvider + : new CompositeFileProvider(overrideFileProvider, manifestEmbeddedFileProvider); + + var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider }; + app.UseDefaultFiles(defaultFilesOptions); + + var staticFileOptions = new StaticFileOptions { FileProvider = fileProvider }; + app.UseStaticFiles(staticFileOptions); + + var constantsFile = ConstantsFile.GetContent(settings); + + app.MapGet("/js/app.constants.js", (HttpContext context) => + { + context.Response.ContentType = MediaTypeNames.Text.JavaScript; + return constantsFile; + }).AllowAnonymous(); + } +} diff --git a/src/Particular.ServicePulse.Core/ServicePulseSettings.cs b/src/Particular.ServicePulse.Core/ServicePulseSettings.cs new file mode 100644 index 0000000000..368c0aa701 --- /dev/null +++ b/src/Particular.ServicePulse.Core/ServicePulseSettings.cs @@ -0,0 +1,112 @@ +namespace ServicePulse; + +using System.Text.Json; + +/// +/// Application Settings for ServicePulse. +/// +public record ServicePulseSettings +{ + /// + /// The location of the ServiceControl API. + /// + public required string ServiceControlUrl { get; init; } + + /// + /// The location of the ServiceControl Monitoring API. + /// + public required string? MonitoringUrl { get; init; } + + /// + /// The default route to navigate to from the root. + /// + public required string DefaultRoute { get; init; } + + /// + /// Flag to enable the pending retry feature. + /// + public required bool ShowPendingRetry { get; init; } + + /// + /// Flag to indicate if ServicePulse is running in integrated mode. + /// + public required bool IsIntegrated { get; init; } + + /// + /// Loads the settings from environment variables. + /// + public static ServicePulseSettings GetFromEnvironmentVariables() + { + var serviceControlUrl = Environment.GetEnvironmentVariable("SERVICECONTROL_URL") ?? "http://localhost:33333/api/"; + + if (!serviceControlUrl.EndsWith("/", StringComparison.Ordinal)) + { + serviceControlUrl += "/"; + } + + if (!serviceControlUrl.EndsWith("api/", StringComparison.Ordinal)) + { + serviceControlUrl += "api/"; + } + + var serviceControlUri = new Uri(serviceControlUrl); + + var monitoringUrls = ParseLegacyMonitoringValue(Environment.GetEnvironmentVariable("MONITORING_URLS")); + var monitoringUrl = Environment.GetEnvironmentVariable("MONITORING_URL"); + + monitoringUrl ??= monitoringUrls; + monitoringUrl ??= "http://localhost:33633/"; + + var monitoringUri = monitoringUrl == "!" ? null : new Uri(monitoringUrl); + + var defaultRoute = Environment.GetEnvironmentVariable("DEFAULT_ROUTE") ?? "/dashboard"; + + var showPendingRetryValue = Environment.GetEnvironmentVariable("SHOW_PENDING_RETRY"); + bool.TryParse(showPendingRetryValue, out var showPendingRetry); + + return new ServicePulseSettings + { + ServiceControlUrl = serviceControlUri.ToString(), + MonitoringUrl = monitoringUri?.ToString(), + DefaultRoute = defaultRoute, + ShowPendingRetry = showPendingRetry, + IsIntegrated = false + }; + } + + static string? ParseLegacyMonitoringValue(string? value) + { + if (value is null) + { + return null; + } + + var cleanedValue = value.Replace('\'', '"'); + var json = $$"""{"Addresses":{{cleanedValue}}}"""; + + MonitoringUrls? result; + + try + { + result = JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + return null; + } + + var addresses = result?.Addresses; + + if (addresses is not null && addresses.Length > 0) + { + return addresses[0]; + } + + return null; + } + + class MonitoringUrls + { + public string[] Addresses { get; set; } = []; + } +} diff --git a/src/ServicePulse.Host.Tests/Owin/StaticMiddlewareTests.cs b/src/ServicePulse.Host.Tests/Owin/StaticMiddlewareTests.cs index f6036cf31e..a29503dfba 100644 --- a/src/ServicePulse.Host.Tests/Owin/StaticMiddlewareTests.cs +++ b/src/ServicePulse.Host.Tests/Owin/StaticMiddlewareTests.cs @@ -129,7 +129,7 @@ public async Task Should_find_prefer_constants_file_on_disk_over_embedded_if_bot } }; await middleware.Invoke(context); - const long sizeOfFileOnDisk = 215; // this is the /app/js/app.constants.js file + const long sizeOfFileOnDisk = 239; // this is the /app/js/app.constants.js file Assert.That(context.Response.ContentLength, Is.EqualTo(sizeOfFileOnDisk)); Assert.That(context.Response.ContentType, Is.EqualTo("application/javascript")); } diff --git a/src/ServicePulse.Host/Hosting/HostArguments.cs b/src/ServicePulse.Host/Hosting/HostArguments.cs index 0a7dc0d000..76f65ac939 100644 --- a/src/ServicePulse.Host/Hosting/HostArguments.cs +++ b/src/ServicePulse.Host/Hosting/HostArguments.cs @@ -2,9 +2,6 @@ namespace ServicePulse.Host.Hosting { using System; using System.Collections.Generic; -#if DEBUG - using System.Diagnostics; -#endif using System.IO; using System.Linq; using System.Reflection; @@ -355,8 +352,7 @@ void ValidateArgs() goto case ExecutionMode.Run; case ExecutionMode.Run: - Uri spUri; - if (!Uri.TryCreate(Url, UriKind.Absolute, out spUri) || (!validProtocols.Contains(spUri.Scheme, StringComparer.OrdinalIgnoreCase))) + if (!Uri.TryCreate(Url, UriKind.Absolute, out Uri spUri) || (!validProtocols.Contains(spUri.Scheme, StringComparer.OrdinalIgnoreCase))) { throw new Exception("The value specified for 'url' is not a valid URL"); } diff --git a/src/ServicePulse.Tests/ServicePulse.Tests.csproj b/src/ServicePulse.Tests/ServicePulse.Tests.csproj index a4dfa7b200..f012869f33 100644 --- a/src/ServicePulse.Tests/ServicePulse.Tests.csproj +++ b/src/ServicePulse.Tests/ServicePulse.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false @@ -13,8 +13,8 @@ - - + + @@ -23,4 +23,4 @@ - + \ No newline at end of file diff --git a/src/ServicePulse.sln b/src/ServicePulse.sln index 1114063682..10e720dc9d 100644 --- a/src/ServicePulse.sln +++ b/src/ServicePulse.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32319.34 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServicePulse.Host", "ServicePulse.Host\ServicePulse.Host.csproj", "{D120B791-BD1B-4E06-B4E1-69801A73209B}" EndProject @@ -38,6 +38,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse", "ServicePuls EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse.Tests", "ServicePulse.Tests\ServicePulse.Tests.csproj", "{9B75F526-937E-4B25-A9F6-2862129993EB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Particular.ServicePulse.Core", "Particular.ServicePulse.Core\Particular.ServicePulse.Core.csproj", "{8FCA3827-719C-49B7-A143-E48461D60F3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -94,6 +96,10 @@ Global {9B75F526-937E-4B25-A9F6-2862129993EB}.Debug|Any CPU.Build.0 = Debug|Any CPU {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.Build.0 = Release|Any CPU + {8FCA3827-719C-49B7-A143-E48461D60F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FCA3827-719C-49B7-A143-E48461D60F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FCA3827-719C-49B7-A143-E48461D60F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FCA3827-719C-49B7-A143-E48461D60F3C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ServicePulse/ConstantsFile.cs b/src/ServicePulse/ConstantsFile.cs deleted file mode 100644 index fc76fb95da..0000000000 --- a/src/ServicePulse/ConstantsFile.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace ServicePulse; - -using System.Reflection; - -class ConstantsFile -{ - public static string GetContent(Settings settings) - { - var version = GetVersionInformation(); - - string serviceControlUrl; - string monitoringUrl; - - if (settings.EnableReverseProxy) - { - serviceControlUrl = "/api/"; - monitoringUrl = settings.MonitoringUri == null ? "!" : "/monitoring-api/"; - } - else - { - // When HTTPS is enabled, upgrade backend URLs to HTTPS - var scUri = settings.HttpsEnabled - ? UpgradeToHttps(settings.ServiceControlUri) - : settings.ServiceControlUri; - serviceControlUrl = scUri.ToString(); - - if (settings.MonitoringUri != null) - { - var mUri = settings.HttpsEnabled - ? UpgradeToHttps(settings.MonitoringUri) - : settings.MonitoringUri; - monitoringUrl = mUri.ToString(); - } - else - { - monitoringUrl = "!"; - } - } - - var constantsFile = $$""" -window.defaultConfig = { - default_route: '{{settings.DefaultRoute}}', - version: '{{version}}', - service_control_url: '{{serviceControlUrl}}', - monitoring_urls: ['{{monitoringUrl}}'], - showPendingRetry: {{(settings.ShowPendingRetry ? "true" : "false")}}, -} -"""; - - return constantsFile; - } - - static string GetVersionInformation() - { - var majorMinorPatch = "0.0.0"; - - var attributes = Assembly.GetExecutingAssembly().GetCustomAttributes(); - - foreach (var attribute in attributes) - { - if (attribute.Key == "MajorMinorPatch") - { - majorMinorPatch = attribute.Value ?? "0.0.0"; - } - } - - return majorMinorPatch; - } - - static Uri UpgradeToHttps(Uri uri) - { - if (uri.Scheme == Uri.UriSchemeHttps) - { - return uri; - } - - var builder = new UriBuilder(uri) - { - Scheme = Uri.UriSchemeHttps, - Port = uri.IsDefaultPort ? -1 : uri.Port - }; - - return builder.Uri; - } -} diff --git a/src/ServicePulse/Dockerfile b/src/ServicePulse/Dockerfile index baf02139c1..61574cc16c 100644 --- a/src/ServicePulse/Dockerfile +++ b/src/ServicePulse/Dockerfile @@ -7,7 +7,7 @@ RUN npm install RUN npm run build # Host build image -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG TARGETARCH WORKDIR / ENV CI=true @@ -15,7 +15,7 @@ COPY --from=frontend . . RUN dotnet publish src/ServicePulse/ServicePulse.csproj -a $TARGETARCH -o /app # Host runtime image -FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled-composite +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-composite WORKDIR /app ENV ASPNETCORE_HTTP_PORTS=9090 diff --git a/src/ServicePulse/Program.cs b/src/ServicePulse/Program.cs index 9b4ca85024..69aea49208 100644 --- a/src/ServicePulse/Program.cs +++ b/src/ServicePulse/Program.cs @@ -1,49 +1,35 @@ -using System.Net.Mime; -using Microsoft.Extensions.FileProviders; using ServicePulse; var builder = WebApplication.CreateBuilder(args); -var settings = Settings.GetFromEnvironmentVariables(); +var settings = ServicePulseSettings.GetFromEnvironmentVariables(); +var hostSettings = ServicePulseHostSettings.GetFromEnvironmentVariables(); + +hostSettings.UpdateApplicationSettings(ref settings); // Configure Kestrel for HTTPS if enabled -builder.ConfigureHttps(settings); +builder.ConfigureHttps(hostSettings); -if (settings.EnableReverseProxy) +if (hostSettings.EnableReverseProxy) { - var (routes, clusters) = ReverseProxy.GetConfiguration(settings); + var (routes, clusters) = ReverseProxy.GetConfiguration(ref settings); builder.Services.AddReverseProxy().LoadFromMemory(routes, clusters); } var app = builder.Build(); // Forwarded headers must be first in the pipeline for correct scheme/host detection -app.UseForwardedHeaders(settings); +app.UseForwardedHeaders(hostSettings); // HTTPS middleware (HSTS and redirect) -app.UseHttpsConfiguration(settings); - -var manifestEmbeddedFileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); -var fileProvider = new CompositeFileProvider(builder.Environment.WebRootFileProvider, manifestEmbeddedFileProvider); - -var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider }; -app.UseDefaultFiles(defaultFilesOptions); +app.UseHttpsConfiguration(hostSettings); -var staticFileOptions = new StaticFileOptions { FileProvider = fileProvider }; -app.UseStaticFiles(staticFileOptions); - -if (settings.EnableReverseProxy) +if (hostSettings.EnableReverseProxy) { app.MapReverseProxy(); } -var constantsFile = ConstantsFile.GetContent(settings); - -app.MapGet("/js/app.constants.js", (HttpContext context) => -{ - context.Response.ContentType = MediaTypeNames.Text.JavaScript; - return constantsFile; -}); +app.UseServicePulse(settings, builder.Environment.WebRootFileProvider); app.Run(); diff --git a/src/ServicePulse/ReverseProxy.cs b/src/ServicePulse/ReverseProxy.cs index 61e11c833f..6714265951 100644 --- a/src/ServicePulse/ReverseProxy.cs +++ b/src/ServicePulse/ReverseProxy.cs @@ -5,22 +5,18 @@ static class ReverseProxy { - public static (List routes, List clusters) GetConfiguration(Settings settings) + public static (List routes, List clusters) GetConfiguration(ref ServicePulseSettings settings) { var routes = new List(); var clusters = new List(); - // When HTTPS is enabled on ServicePulse, assume ServiceControl also uses HTTPS - var serviceControlUri = settings.HttpsEnabled - ? UpgradeToHttps(settings.ServiceControlUri) - : settings.ServiceControlUri; var serviceControlInstance = new ClusterConfig { ClusterId = "serviceControlInstance", Destinations = new Dictionary { - { "instance", new DestinationConfig { Address = serviceControlUri.ToString() } } + { "instance", new DestinationConfig { Address = settings.ServiceControlUrl } } } }; var serviceControlRoute = new RouteConfig @@ -35,20 +31,16 @@ public static (List routes, List clusters) GetConfig clusters.Add(serviceControlInstance); routes.Add(serviceControlRoute); + settings = settings with { ServiceControlUrl = "/api/" }; - if (settings.MonitoringUri != null) + if (settings.MonitoringUrl != null) { - // When HTTPS is enabled on ServicePulse, assume Monitoring also uses HTTPS - var monitoringUri = settings.HttpsEnabled - ? UpgradeToHttps(settings.MonitoringUri) - : settings.MonitoringUri; - var monitoringInstance = new ClusterConfig { ClusterId = "monitoringInstance", Destinations = new Dictionary { - { "instance", new DestinationConfig { Address = monitoringUri.ToString() } } + { "instance", new DestinationConfig { Address = settings.MonitoringUrl } } } }; @@ -61,24 +53,9 @@ public static (List routes, List clusters) GetConfig clusters.Add(monitoringInstance); routes.Add(monitoringRoute); + settings = settings with { MonitoringUrl = "/monitoring-api/" }; } return (routes, clusters); } - - static Uri UpgradeToHttps(Uri uri) - { - if (uri.Scheme == Uri.UriSchemeHttps) - { - return uri; - } - - var builder = new UriBuilder(uri) - { - Scheme = Uri.UriSchemeHttps, - Port = uri.IsDefaultPort ? -1 : uri.Port - }; - - return builder.Uri; - } } diff --git a/src/ServicePulse/ServicePulse.csproj b/src/ServicePulse/ServicePulse.csproj index 4230dd8456..6d03f196c9 100644 --- a/src/ServicePulse/ServicePulse.csproj +++ b/src/ServicePulse/ServicePulse.csproj @@ -1,10 +1,9 @@ - + - net8.0 + net10.0 enable enable - true false @@ -13,13 +12,12 @@ - - + - + \ No newline at end of file diff --git a/src/ServicePulse/Settings.cs b/src/ServicePulse/ServicePulseHostSettings.cs similarity index 84% rename from src/ServicePulse/Settings.cs rename to src/ServicePulse/ServicePulseHostSettings.cs index 494136b38e..87b1705d37 100644 --- a/src/ServicePulse/Settings.cs +++ b/src/ServicePulse/ServicePulseHostSettings.cs @@ -1,21 +1,11 @@ namespace ServicePulse; using System.Net; -using System.Text.Json; using Microsoft.Extensions.Logging; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; -class Settings +class ServicePulseHostSettings { - static readonly ILogger logger = LoggerUtil.CreateStaticLogger(); - - public required Uri ServiceControlUri { get; init; } - - public required Uri? MonitoringUri { get; init; } - - public required string DefaultRoute { get; init; } - - public required bool ShowPendingRetry { get; init; } + static readonly ILogger logger = LoggerUtil.CreateStaticLogger(); public required bool EnableReverseProxy { get; init; } @@ -79,35 +69,8 @@ class Settings /// public required bool HttpsHstsIncludeSubDomains { get; init; } - public static Settings GetFromEnvironmentVariables() + public static ServicePulseHostSettings GetFromEnvironmentVariables() { - var serviceControlUrl = Environment.GetEnvironmentVariable("SERVICECONTROL_URL") ?? "http://localhost:33333/api/"; - - if (!serviceControlUrl.EndsWith("/", StringComparison.Ordinal)) - { - serviceControlUrl += "/"; - } - - if (!serviceControlUrl.EndsWith("api/", StringComparison.Ordinal)) - { - serviceControlUrl += "api/"; - } - - var serviceControlUri = new Uri(serviceControlUrl); - - var monitoringUrls = ParseLegacyMonitoringValue(Environment.GetEnvironmentVariable("MONITORING_URLS")); - var monitoringUrl = Environment.GetEnvironmentVariable("MONITORING_URL"); - - monitoringUrl ??= monitoringUrls; - monitoringUrl ??= "http://localhost:33633/"; - - var monitoringUri = monitoringUrl == "!" ? null : new Uri(monitoringUrl); - - var defaultRoute = Environment.GetEnvironmentVariable("DEFAULT_ROUTE") ?? "/dashboard"; - - var showPendingRetryValue = Environment.GetEnvironmentVariable("SHOW_PENDING_RETRY"); - bool.TryParse(showPendingRetryValue, out var showPendingRetry); - var enableReverseProxyValue = Environment.GetEnvironmentVariable("ENABLE_REVERSE_PROXY"); if (!bool.TryParse(enableReverseProxyValue, out var enableReverseProxy)) @@ -162,12 +125,8 @@ public static Settings GetFromEnvironmentVariables() Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_HSTSINCLUDESUBDOMAINS"), defaultValue: false); - var settings = new Settings + var settings = new ServicePulseHostSettings { - ServiceControlUri = serviceControlUri, - MonitoringUri = monitoringUri, - DefaultRoute = defaultRoute, - ShowPendingRetry = showPendingRetry, EnableReverseProxy = enableReverseProxy, ForwardedHeadersEnabled = forwardedHeadersEnabled, ForwardedHeadersTrustAllProxies = forwardedHeadersTrustAllProxies, @@ -187,6 +146,7 @@ public static Settings GetFromEnvironmentVariables() settings.ValidateHttpsCertificateConfiguration(); settings.LogHttpsConfiguration(); + return settings; } @@ -259,42 +219,6 @@ static IReadOnlyList ParseNetworks(string? value) return networks; } - static string? ParseLegacyMonitoringValue(string? value) - { - if (value is null) - { - return null; - } - - var cleanedValue = value.Replace('\'', '"'); - var json = $$"""{"Addresses":{{cleanedValue}}}"""; - - MonitoringUrls? result; - - try - { - result = JsonSerializer.Deserialize(json); - } - catch (JsonException) - { - return null; - } - - var addresses = result?.Addresses; - - if (addresses is not null && addresses.Length > 0) - { - return addresses[0]; - } - - return null; - } - - class MonitoringUrls - { - public string[] Addresses { get; set; } = []; - } - /// /// Logs the forwarded headers configuration and warns about potential misconfigurations. /// @@ -420,4 +344,37 @@ void LogHttpsConfiguration() logger.LogInformation("SERVICEPULSE_HTTPS_PORT is not configured. HTTPS redirect will be ignored"); } } + + public void UpdateApplicationSettings(ref ServicePulseSettings settings) + { + // When HTTPS is enabled on ServicePulse, assume ServiceControl (and Monitoring, if configured) also uses HTTPS + if (HttpsEnabled) + { + settings = settings with + { + ServiceControlUrl = UpgradeToHttps(settings.ServiceControlUrl), + MonitoringUrl = settings.MonitoringUrl is not null + ? UpgradeToHttps(settings.MonitoringUrl) + : null + }; + } + } + + static string UpgradeToHttps(string url) + { + var uri = new Uri(url); + + if (uri.Scheme == Uri.UriSchemeHttps) + { + return url; + } + + var builder = new UriBuilder(uri) + { + Scheme = Uri.UriSchemeHttps, + Port = uri.IsDefaultPort ? -1 : uri.Port + }; + + return builder.Uri.ToString(); + } } diff --git a/src/ServicePulse/WebApplicationBuilderExtensions.cs b/src/ServicePulse/WebApplicationBuilderExtensions.cs index 1645af9da8..ecbc9491f3 100644 --- a/src/ServicePulse/WebApplicationBuilderExtensions.cs +++ b/src/ServicePulse/WebApplicationBuilderExtensions.cs @@ -4,7 +4,7 @@ namespace ServicePulse; static class WebApplicationBuilderExtensions { - public static void ConfigureHttps(this WebApplicationBuilder builder, Settings settings) + public static void ConfigureHttps(this WebApplicationBuilder builder, ServicePulseHostSettings settings) { // EnableHsts is disabled by default // Hsts is automatically disabled in Development environments @@ -51,7 +51,7 @@ public static void ConfigureHttps(this WebApplicationBuilder builder, Settings s } } - static X509Certificate2 LoadCertificate(Settings settings) + static X509Certificate2 LoadCertificate(ServicePulseHostSettings settings) { var certPath = settings.HttpsCertificatePath ?? throw new InvalidOperationException("HTTPS is enabled but HTTPS_CERTIFICATEPATH is not set."); @@ -62,7 +62,7 @@ static X509Certificate2 LoadCertificate(Settings settings) } return string.IsNullOrEmpty(settings.HttpsCertificatePassword) - ? new X509Certificate2(certPath) - : new X509Certificate2(certPath, settings.HttpsCertificatePassword); + ? X509CertificateLoader.LoadPkcs12FromFile(certPath, null) + : X509CertificateLoader.LoadPkcs12FromFile(certPath, settings.HttpsCertificatePassword); } } diff --git a/src/ServicePulse/WebApplicationExtensions.cs b/src/ServicePulse/WebApplicationExtensions.cs index 3f2f76da01..319a992719 100644 --- a/src/ServicePulse/WebApplicationExtensions.cs +++ b/src/ServicePulse/WebApplicationExtensions.cs @@ -4,7 +4,7 @@ namespace ServicePulse; static class WebApplicationExtensions { - public static void UseForwardedHeaders(this WebApplication app, Settings settings) + public static void UseForwardedHeaders(this WebApplication app, ServicePulseHostSettings settings) { // Register debug endpoint first (before early return) so it's always available in Development if (app.Environment.IsDevelopment()) @@ -27,7 +27,7 @@ public static void UseForwardedHeaders(this WebApplication app, Settings setting // Configuration var knownProxies = settings.ForwardedHeadersKnownProxies.Select(p => p.ToString()).ToArray(); - var knownNetworks = settings.ForwardedHeadersKnownNetworks.Select(n => $"{n.Prefix}/{n.PrefixLength}").ToArray(); + var knownNetworks = settings.ForwardedHeadersKnownNetworks.Select(n => n.ToString()).ToArray(); return new { @@ -58,7 +58,7 @@ public static void UseForwardedHeaders(this WebApplication app, Settings setting // Clear default loopback-only restrictions options.KnownProxies.Clear(); - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); // Enabled by default if (settings.ForwardedHeadersTrustAllProxies) @@ -76,14 +76,14 @@ public static void UseForwardedHeaders(this WebApplication app, Settings setting foreach (var network in settings.ForwardedHeadersKnownNetworks) { - options.KnownNetworks.Add(network); + options.KnownIPNetworks.Add(network); } } app.UseForwardedHeaders(options); } - public static void UseHttpsConfiguration(this WebApplication app, Settings settings) + public static void UseHttpsConfiguration(this WebApplication app, ServicePulseHostSettings settings) { // EnableHsts is disabled by default // Hsts is automatically disabled in Development environments