From cf6e43030b7dc6c12e6b2fa4710ef1949209568f Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Tue, 30 Jun 2026 08:52:11 +0200 Subject: [PATCH 1/7] feat(catalog): add per-cluster catalog proxy client, endpoints and tests --- .../TestCatalogApplicationService.cs | 236 +++++++++++++ .../Catalog/TestCatalogClient.cs | 91 +++++ .../Application/CatalogApplicationService.cs | 311 ++++++++++++++++++ src/SelfService/Configuration/Api.cs | 2 + src/SelfService/Configuration/Domain.cs | 12 + .../Configuration/Observability.cs | 2 +- src/SelfService/Configuration/Security.cs | 7 +- .../Infrastructure/Api/ApiResourceFactory.cs | 18 +- .../Api/Capabilities/CapabilityController.cs | 38 ++- .../CapabilityDetailsApiResource.cs | 5 +- .../Api/Catalog/CatalogApiResourceFactory.cs | 246 ++++++++++++++ .../Api/Catalog/CatalogApiResources.cs | 198 +++++++++++ .../Api/Catalog/CatalogController.cs | 72 ++++ .../Infrastructure/Catalog/CatalogClient.cs | 71 ++++ .../Infrastructure/Catalog/CatalogConfig.cs | 63 ++++ .../Infrastructure/Catalog/CatalogDtos.cs | 281 ++++++++++++++++ .../Catalog/CatalogTokenProvider.cs | 56 ++++ src/SelfService/Logging.cs | 4 + 18 files changed, 1708 insertions(+), 5 deletions(-) create mode 100644 src/SelfService.Tests/Application/TestCatalogApplicationService.cs create mode 100644 src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs create mode 100644 src/SelfService/Application/CatalogApplicationService.cs create mode 100644 src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs create mode 100644 src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs create mode 100644 src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs create mode 100644 src/SelfService/Infrastructure/Catalog/CatalogClient.cs create mode 100644 src/SelfService/Infrastructure/Catalog/CatalogConfig.cs create mode 100644 src/SelfService/Infrastructure/Catalog/CatalogDtos.cs create mode 100644 src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs diff --git a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs new file mode 100644 index 00000000..59a2a5e7 --- /dev/null +++ b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs @@ -0,0 +1,236 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SelfService.Application; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Catalog; +using SelfService.Tests.Builders; + +namespace SelfService.Tests.Application; + +public class TestCatalogApplicationService +{ + private static CatalogApplicationService BuildService( + CatalogConfig config, + ICatalogClient catalogClient, + ICapabilityRepository capabilityRepository + ) + { + return new CatalogApplicationService( + config, + catalogClient, + capabilityRepository, + new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance + ); + } + + private static CatalogConfig SingleCluster(string cluster = "local") => + new(new[] { new CatalogClusterEndpoint(cluster, new Uri("http://ssu-catalog:8080")) }, scope: ""); + + // ---- endpoint-registry parser ---- + + [Fact] + public void ParseEndpoints_parses_cluster_url_csv() + { + var result = CatalogConfig.ParseEndpoints("prod=https://a.example, dev=https://b.example"); + + Assert.Equal(2, result.Count); + Assert.Equal("prod", result[0].Cluster); + Assert.Equal(new Uri("https://a.example"), result[0].Url); + Assert.Equal("dev", result[1].Cluster); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData(" ")] + [InlineData("no-equals-sign")] + [InlineData("=https://missing-cluster")] + [InlineData("cluster=")] + [InlineData("cluster=not a uri")] + public void ParseEndpoints_skips_blank_and_malformed(string? raw) + { + Assert.Empty(CatalogConfig.ParseEndpoints(raw)); + } + + // ---- merge + capability filter + name join ---- + + [Fact] + public async Task Merge_keeps_only_capability_owned_apps_and_joins_name() + { + var capability = A.Capability.WithId(CapabilityId.Parse("team-alpha-abcde")).WithName("Team Alpha").Build(); + + var snapshot = new CatalogSnapshotDto + { + Applications = + { + new ApplicationEntryDto + { + Namespace = "team-alpha-abcde", + Name = "api", + Kind = "Deployment", + CapabilityId = "team-alpha-abcde", + }, + new ApplicationEntryDto + { + Namespace = "orphan-xyzab", + Name = "stray", + Kind = "Deployment", + CapabilityId = "orphan-xyzab", // no matching capability → filtered out + }, + }, + }; + + var catalogClient = new Mock(); + catalogClient.Setup(x => x.GetCatalog(It.IsAny(), It.IsAny())).ReturnsAsync(snapshot); + + var capabilityRepository = new Mock(); + capabilityRepository + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(new[] { capability }); + + var service = BuildService(SingleCluster(), catalogClient.Object, capabilityRepository.Object); + + var result = await service.ListApplications(new ApplicationFilters()); + + var app = Assert.Single(result.Items); + Assert.Equal("api", app.Name); + Assert.Equal("Team Alpha", app.CapabilityName); + Assert.Equal("local", app.Cluster); // stamped from the registry + Assert.True(result.Availability.CatalogAvailable); + Assert.Equal(1, result.Availability.ClustersQueried); + Assert.Equal(0, result.Availability.ClustersFailed); + } + + [Fact] + public async Task GetDeploymentsForCapability_filters_by_capability_id() + { + var alpha = A.Capability.WithId(CapabilityId.Parse("team-alpha-abcde")).WithName("Team Alpha").Build(); + var beta = A.Capability.WithId(CapabilityId.Parse("team-beta-fghij")).WithName("Team Beta").Build(); + + var snapshot = new CatalogSnapshotDto + { + Applications = + { + new ApplicationEntryDto + { + Namespace = "team-alpha-abcde", + Name = "api", + CapabilityId = "team-alpha-abcde", + }, + new ApplicationEntryDto + { + Namespace = "team-beta-fghij", + Name = "worker", + CapabilityId = "team-beta-fghij", + }, + }, + }; + + var catalogClient = new Mock(); + catalogClient.Setup(x => x.GetCatalog(It.IsAny(), It.IsAny())).ReturnsAsync(snapshot); + + var capabilityRepository = new Mock(); + capabilityRepository + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(new[] { alpha, beta }); + + var service = BuildService(SingleCluster(), catalogClient.Object, capabilityRepository.Object); + + var result = await service.GetDeploymentsForCapability(CapabilityId.Parse("team-alpha-abcde")); + + var app = Assert.Single(result.Items); + Assert.Equal("api", app.Name); + } + + [Fact] + public async Task HasDocs_filter_selects_apps_with_api_docs() + { + var capability = A.Capability.WithId(CapabilityId.Parse("team-alpha-abcde")).WithName("Team Alpha").Build(); + + var snapshot = new CatalogSnapshotDto + { + Applications = + { + new ApplicationEntryDto + { + Namespace = "team-alpha-abcde", + Name = "documented", + CapabilityId = "team-alpha-abcde", + Services = + { + new ServiceRefDto { Name = "svc", ApiDocs = { new ApiDocInfoDto { Path = "/swagger" } } }, + }, + }, + new ApplicationEntryDto + { + Namespace = "team-alpha-abcde", + Name = "undocumented", + CapabilityId = "team-alpha-abcde", + }, + }, + }; + + var catalogClient = new Mock(); + catalogClient.Setup(x => x.GetCatalog(It.IsAny(), It.IsAny())).ReturnsAsync(snapshot); + + var capabilityRepository = new Mock(); + capabilityRepository + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(new[] { capability }); + + var service = BuildService(SingleCluster(), catalogClient.Object, capabilityRepository.Object); + + var withDocs = await service.ListApplications(new ApplicationFilters(HasDocs: true)); + Assert.Equal("documented", Assert.Single(withDocs.Items).Name); + + var withoutDocs = await service.ListApplications(new ApplicationFilters(HasDocs: false)); + Assert.Equal("undocumented", Assert.Single(withoutDocs.Items).Name); + } + + // ---- unavailability contract ---- + + [Fact] + public async Task All_clusters_fail_reports_unavailable_with_no_items() + { + var catalogClient = new Mock(); + catalogClient + .Setup(x => x.GetCatalog(It.IsAny(), It.IsAny())) + .ReturnsAsync((CatalogSnapshotDto?)null); // every cluster fails + + var capabilityRepository = new Mock(); + capabilityRepository + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(Array.Empty()); + + var service = BuildService(SingleCluster(), catalogClient.Object, capabilityRepository.Object); + + var result = await service.ListApplications(new ApplicationFilters()); + + Assert.Empty(result.Items); + Assert.False(result.Availability.CatalogAvailable); + Assert.Equal(1, result.Availability.ClustersQueried); + Assert.Equal(1, result.Availability.ClustersFailed); + } + + // ---- token provider ---- + + [Fact] + public async Task TokenProvider_unconfigured_scope_returns_null_without_acquiring() + { + var tokenAcquisition = new Mock(); + var config = new CatalogConfig(Array.Empty(), scope: ""); + + var provider = new CatalogTokenProvider( + config, + tokenAcquisition.Object, + NullLogger.Instance + ); + + var token = await provider.GetAccessToken(); + + Assert.Null(token); + tokenAcquisition.VerifyNoOtherCalls(); + } +} diff --git a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs new file mode 100644 index 00000000..5ad73679 --- /dev/null +++ b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs @@ -0,0 +1,91 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using SelfService.Infrastructure.Catalog; + +namespace SelfService.Tests.Infrastructure.Catalog; + +public class TestCatalogClient +{ + private static HttpClient MockHttp(HttpStatusCode status, string body) + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { StatusCode = status, Content = new StringContent(body) }); + return new HttpClient(handler.Object); + } + + private static ICatalogTokenProvider NoToken() + { + var provider = new Mock(); + provider.Setup(x => x.GetAccessToken(It.IsAny())).ReturnsAsync((string?)null); + return provider.Object; + } + + [Fact] + public async Task GetCatalog_unwraps_envelope_and_deserializes_camelCase() + { + const string json = """ + { + "data": { + "cluster": "local", + "applications": [ + { + "namespace": "team-alpha-abcde", + "name": "api", + "kind": "Deployment", + "capabilityId": "team-alpha-abcde", + "replicas": 3, + "readyReplicas": 2, + "services": [ + { "name": "svc", "type": "ClusterIP", "apiDocs": [ { "path": "/swagger", "url": "http://svc/swagger" } ] } + ] + } + ], + "namespaces": [], + "dependencies": [], + "collectedAt": "2026-01-01T00:00:00Z" + }, + "meta": { "collectedAt": "2026-01-01T00:00:00Z", "cluster": "local" } + } + """; + + var client = new CatalogClient( + MockHttp(HttpStatusCode.OK, json), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + Assert.NotNull(snapshot); + Assert.Equal("local", snapshot!.Cluster); + var app = Assert.Single(snapshot.Applications); + Assert.Equal("api", app.Name); + Assert.Equal(3, app.Replicas); + Assert.Equal(2, app.ReadyReplicas); + var service = Assert.Single(app.Services); + Assert.Equal("/swagger", Assert.Single(service.ApiDocs).Path); + } + + [Fact] + public async Task GetCatalog_returns_null_on_non_success_status() + { + var client = new CatalogClient( + MockHttp(HttpStatusCode.InternalServerError, "boom"), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + Assert.Null(snapshot); + } +} diff --git a/src/SelfService/Application/CatalogApplicationService.cs b/src/SelfService/Application/CatalogApplicationService.cs new file mode 100644 index 00000000..b43225e9 --- /dev/null +++ b/src/SelfService/Application/CatalogApplicationService.cs @@ -0,0 +1,311 @@ +using Microsoft.Extensions.Caching.Memory; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Catalog; + +namespace SelfService.Application; + +/// Availability summary surfaced in every catalog endpoint's meta envelope. +public sealed record CatalogAvailability(bool CatalogAvailable, int ClustersQueried, int ClustersFailed); + +/// A query result: the matching items plus the cross-cluster availability summary. +public sealed record CatalogResult(IReadOnlyList Items, CatalogAvailability Availability); + +public sealed record ApplicationFilters( + string? CapabilityId = null, + string? Namespace = null, + string? Kind = null, + string? Query = null, + bool? HasDocs = null +); + +public sealed record DependencyFilters(string? Namespace = null, string? Type = null); + +public interface ICatalogApplicationService +{ + Task> GetDeploymentsForCapability( + CapabilityId capabilityId, + CancellationToken cancellationToken = default + ); + Task> ListApplications( + ApplicationFilters filters, + CancellationToken cancellationToken = default + ); + Task> ListNamespaces(CancellationToken cancellationToken = default); + Task> GetDependencies( + DependencyFilters filters, + CancellationToken cancellationToken = default + ); +} + +/// +/// Caching proxy over the per-cluster ssu-catalog services. On cache miss it fans out one +/// full-snapshot fetch per cluster, concatenates, then once joins on capabilityId against the +/// authoritative Capability data (filtering to capability-owned apps and attaching the name). All +/// query methods read from the single cached merged structure. No persistence. +/// +public class CatalogApplicationService : ICatalogApplicationService +{ + private const string CacheKey = "catalog:merged"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(45); + + // The cross-cluster fan-out populates a shared, cached snapshot, so its lifetime must not be + // tied to any single inbound request. Binding it to a request's RequestAborted token meant a + // browser reload/navigation mid-fetch cancelled the upstream read (TaskCanceledException → + // SocketException ECANCELED), which was then misreported as a failed cluster. Give the fetch + // its own bounded budget instead so a genuinely hung upstream is still capped. + private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20); + + private readonly CatalogConfig _config; + private readonly ICatalogClient _catalogClient; + private readonly ICapabilityRepository _capabilityRepository; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public CatalogApplicationService( + CatalogConfig config, + ICatalogClient catalogClient, + ICapabilityRepository capabilityRepository, + IMemoryCache cache, + ILogger logger + ) + { + _config = config; + _catalogClient = catalogClient; + _capabilityRepository = capabilityRepository; + _cache = cache; + _logger = logger; + } + + public async Task> GetDeploymentsForCapability( + CapabilityId capabilityId, + CancellationToken cancellationToken = default + ) + { + var merged = await GetMerged(); + var id = capabilityId.ToString(); + var items = merged + .Applications.Where(a => string.Equals(a.CapabilityId, id, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return new CatalogResult(items, merged.Availability); + } + + public async Task> ListApplications( + ApplicationFilters filters, + CancellationToken cancellationToken = default + ) + { + var merged = await GetMerged(); + IEnumerable apps = merged.Applications; + + if (!string.IsNullOrWhiteSpace(filters.CapabilityId)) + { + apps = apps.Where(a => + string.Equals(a.CapabilityId, filters.CapabilityId, StringComparison.OrdinalIgnoreCase) + ); + } + if (!string.IsNullOrWhiteSpace(filters.Namespace)) + { + apps = apps.Where(a => string.Equals(a.Namespace, filters.Namespace, StringComparison.OrdinalIgnoreCase)); + } + if (!string.IsNullOrWhiteSpace(filters.Kind)) + { + apps = apps.Where(a => string.Equals(a.Kind, filters.Kind, StringComparison.OrdinalIgnoreCase)); + } + if (!string.IsNullOrWhiteSpace(filters.Query)) + { + apps = apps.Where(a => a.Name.Contains(filters.Query, StringComparison.OrdinalIgnoreCase)); + } + if (filters.HasDocs is { } hasDocs) + { + apps = apps.Where(a => HasDocs(a) == hasDocs); + } + + return new CatalogResult(apps.ToList(), merged.Availability); + } + + public async Task> ListNamespaces(CancellationToken cancellationToken = default) + { + var merged = await GetMerged(); + return new CatalogResult(merged.Namespaces, merged.Availability); + } + + public async Task> GetDependencies( + DependencyFilters filters, + CancellationToken cancellationToken = default + ) + { + var merged = await GetMerged(); + IEnumerable deps = merged.Dependencies; + + if (!string.IsNullOrWhiteSpace(filters.Namespace)) + { + deps = deps.Where(d => + string.Equals(d.Source.Namespace, filters.Namespace, StringComparison.OrdinalIgnoreCase) + || string.Equals(d.Target.Namespace, filters.Namespace, StringComparison.OrdinalIgnoreCase) + ); + } + if (!string.IsNullOrWhiteSpace(filters.Type)) + { + deps = deps.Where(d => string.Equals(d.Type, filters.Type, StringComparison.OrdinalIgnoreCase)); + } + + return new CatalogResult(deps.ToList(), merged.Availability); + } + + private static bool HasDocs(ApplicationEntryDto app) => app.Services.Any(s => s.ApiDocs.Count > 0); + + // Deliberately takes no CancellationToken: the populated snapshot is cached and shared across + // all callers, so the fan-out must not inherit any one request's RequestAborted. Each fetch + // gets an independent timeout budget instead (see FetchTimeout). + private Task GetMerged() + { + return _cache.GetOrCreateAsync( + CacheKey, + async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheTtl; + using var cts = new CancellationTokenSource(FetchTimeout); + return await FetchAndMerge(cts.Token); + } + )!; + } + + private async Task FetchAndMerge(CancellationToken cancellationToken) + { + var registry = _config.Clusters; + + // Single full-snapshot fetch per cluster (decision 9); per-cluster failure → null. + var fetches = registry + .Select(async endpoint => + { + var snapshot = await _catalogClient.GetCatalog(endpoint.Url, cancellationToken); + return (endpoint, snapshot); + }) + .ToList(); + + var results = await Task.WhenAll(fetches); + + var apps = new List(); + var namespaces = new List(); + var dependencies = new List(); + var clustersFailed = 0; + + foreach (var (endpoint, snapshot) in results) + { + if (snapshot is null) + { + clustersFailed++; + continue; + } + + // Stamp the registry cluster name (authoritative) onto every entry. + foreach (var app in snapshot.Applications) + { + app.Cluster = endpoint.Cluster; + } + foreach (var ns in snapshot.Namespaces) + { + ns.Cluster = endpoint.Cluster; + } + + apps.AddRange(snapshot.Applications); + namespaces.AddRange(snapshot.Namespaces); + dependencies.AddRange(snapshot.Dependencies); + } + + var availability = new CatalogAvailability( + CatalogAvailable: registry.Count > 0 && clustersFailed < registry.Count, + ClustersQueried: registry.Count, + ClustersFailed: clustersFailed + ); + + // Join once: resolve each distinct capabilityId to a real Capability, keeping only owned + // apps/namespaces and attaching the authoritative name. + var capabilityNames = await ResolveCapabilityNames(apps, namespaces); + + var ownedApps = apps.Where(a => + TryAttachCapability(a.CapabilityId, capabilityNames, name => a.CapabilityName = name) + ) + .ToList(); + var ownedNamespaces = namespaces + .Where(n => TryAttachCapability(n.CapabilityId, capabilityNames, name => n.CapabilityName = name)) + .ToList(); + + // Keep dependency edges whose source namespace is capability-owned (source is the + // in-cluster app; targets may be external). Best-effort overlay. + var ownedNamespaceKeys = ownedApps + .Select(a => (a.Cluster, a.Namespace)) + .Concat(ownedNamespaces.Select(n => (n.Cluster, n.Name))) + .ToHashSet(); + var ownedDependencies = dependencies + .Where(d => ownedNamespaceKeys.Contains((d.Source.Cluster, d.Source.Namespace))) + .ToList(); + + _logger.LogDebug( + "Catalog merged: {Apps} owned apps, {Namespaces} owned namespaces, {Deps} dependencies across {Queried} clusters ({Failed} failed)", + ownedApps.Count, + ownedNamespaces.Count, + ownedDependencies.Count, + availability.ClustersQueried, + availability.ClustersFailed + ); + + return new MergedCatalog(ownedApps, ownedNamespaces, ownedDependencies, availability); + } + + private async Task> ResolveCapabilityNames( + IEnumerable apps, + IEnumerable namespaces + ) + { + var candidateIds = apps.Select(a => a.CapabilityId) + .Concat(namespaces.Select(n => n.CapabilityId)) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var parsed = new List(); + foreach (var id in candidateIds) + { + if (CapabilityId.TryParse(id, out var capabilityId)) + { + parsed.Add(capabilityId); + } + } + + if (parsed.Count == 0) + { + return new Dictionary(); + } + + var capabilities = await _capabilityRepository.GetByIds(parsed); + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var capability in capabilities) + { + names[capability.Id.ToString()] = capability.Name; + } + return names; + } + + private static bool TryAttachCapability( + string capabilityId, + IReadOnlyDictionary capabilityNames, + Action attachName + ) + { + if (string.IsNullOrWhiteSpace(capabilityId) || !capabilityNames.TryGetValue(capabilityId, out var name)) + { + return false; + } + attachName(name); + return true; + } + + /// The cached, merged + capability-joined catalog. All query methods read from this. + private sealed record MergedCatalog( + IReadOnlyList Applications, + IReadOnlyList Namespaces, + IReadOnlyList Dependencies, + CatalogAvailability Availability + ); +} diff --git a/src/SelfService/Configuration/Api.cs b/src/SelfService/Configuration/Api.cs index 37270ec8..219b222e 100644 --- a/src/SelfService/Configuration/Api.cs +++ b/src/SelfService/Configuration/Api.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using SelfService.Infrastructure.Api; +using SelfService.Infrastructure.Api.Catalog; namespace SelfService.Configuration; @@ -33,5 +34,6 @@ public static void AddApi(this WebApplicationBuilder builder) }); builder.Services.AddTransient(); + builder.Services.AddTransient(); } } diff --git a/src/SelfService/Configuration/Domain.cs b/src/SelfService/Configuration/Domain.cs index 56e8bfdc..27fb1123 100644 --- a/src/SelfService/Configuration/Domain.cs +++ b/src/SelfService/Configuration/Domain.cs @@ -8,6 +8,7 @@ using SelfService.Infrastructure.Api.Prometheus; using SelfService.Infrastructure.Api.System; using SelfService.Infrastructure.BackgroundJobs; +using SelfService.Infrastructure.Catalog; using SelfService.Infrastructure.Kafka; using SelfService.Infrastructure.Persistence; using SelfService.Infrastructure.Persistence.Queries; @@ -189,5 +190,16 @@ public static void AddDomain(this WebApplicationBuilder builder) { client.BaseAddress = confluentGatewayApiEndpoint; }); + + // ssu-catalog integration — caching proxy, no persistence. The endpoint registry is + // per-cluster (SS_CATALOG_ENDPOINTS); the downstream scope is shared (SS_CATALOG_SCOPE). + var catalogEndpoints = CatalogConfig.ParseEndpoints(builder.Configuration["SS_CATALOG_ENDPOINTS"]); + var catalogScope = builder.Configuration["SS_CATALOG_SCOPE"]; + builder.Services.AddSingleton(new CatalogConfig(catalogEndpoints, catalogScope)); + builder.Services.AddMemoryCache(); + builder.Services.AddScoped(); + // No BaseAddress: CatalogClient calls each per-cluster URL from the registry explicitly. + builder.Services.AddHttpClient(); + builder.Services.AddTransient(); } } diff --git a/src/SelfService/Configuration/Observability.cs b/src/SelfService/Configuration/Observability.cs index 5e69b1ba..284799ce 100644 --- a/src/SelfService/Configuration/Observability.cs +++ b/src/SelfService/Configuration/Observability.cs @@ -41,7 +41,7 @@ public static void AddObservability(this WebApplicationBuilder builder) conf.AddOtlpExporter(); conf.AddPrometheusHttpListener(conf => { - conf.UriPrefixes = new string[] { "http://*:8888/" }; + conf.UriPrefixes = new string[] { "http://localhost:8888/" }; }); }); } diff --git a/src/SelfService/Configuration/Security.cs b/src/SelfService/Configuration/Security.cs index e19441b8..6194b436 100644 --- a/src/SelfService/Configuration/Security.cs +++ b/src/SelfService/Configuration/Security.cs @@ -12,7 +12,12 @@ public static void AddSecurity(this WebApplicationBuilder builder) { builder .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + // Enables ITokenAcquisition so selfservice-api can acquire a client-credentials + // (app-only) token as its own service principal to call ssu-catalog downstream. + // In local dev SS_CATALOG_SCOPE is empty, so no token is acquired (see CatalogTokenProvider). + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); builder.Services.AddAuthorization(); diff --git a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs index fcd7a188..22f915e3 100644 --- a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs @@ -625,6 +625,21 @@ private ResourceLink CreateMembersLinkFor(Capability capability) ); } + private ResourceLink CreateDeploymentsLinkFor(Capability capability) + { + // Open read (like members): the link is always present so the portal section always renders. + return new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetCapabilityDeployments), + controller: GetNameOf(), + values: new { id = capability.Id } + ) ?? "", + rel: "related", + allow: Allow.Get + ); + } + private ResourceLink CreateClusterAccessLinkFor(Capability capability) { return new ResourceLink( @@ -789,7 +804,8 @@ public async Task Convert(Capability capability, b configurationLevel: CreateConfigurationLevelLinkFor(capability), selfAssessments: await CreateSelfAssessmentsLinkFor(capability), requirementScore: CreateRequirementScoreLinkFor(capability), - servicePrincipalMembers: await CreateServicePrincipalMembersLinkFor(capability) + servicePrincipalMembers: await CreateServicePrincipalMembersLinkFor(capability), + deployments: CreateDeploymentsLinkFor(capability) ) ); } diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index df4e76f9..36c32e88 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -5,6 +5,7 @@ using SelfService.Domain.Models; using SelfService.Domain.Queries; using SelfService.Domain.Services; +using SelfService.Infrastructure.Api.Catalog; using SelfService.Infrastructure.Api.RBAC; using SelfService.Infrastructure.Api.RBAC.Dto; using SelfService.Infrastructure.Persistence; @@ -41,6 +42,8 @@ public class CapabilityController : ControllerBase private readonly IAwsEC2QueriesApplicationService _awsEC2QueriesApplicationService; private readonly IRbacApplicationService _rbacApplicationService; private readonly IRequirementsMetricService _requirementsMetricService; + private readonly ICatalogApplicationService _catalogApplicationService; + private readonly CatalogApiResourceFactory _catalogApiResourceFactory; public CapabilityController( ICapabilityMembersQuery membersQuery, @@ -65,7 +68,9 @@ public CapabilityController( ISelfAssessmentOptionRepository selfAssessmentOptionRepository, IAwsEC2QueriesApplicationService awsEC2QueriesApplicationService, IRbacApplicationService rbacApplicationService, - IRequirementsMetricService requirementsMetricService + IRequirementsMetricService requirementsMetricService, + ICatalogApplicationService catalogApplicationService, + CatalogApiResourceFactory catalogApiResourceFactory ) { _membersQuery = membersQuery; @@ -91,6 +96,8 @@ IRequirementsMetricService requirementsMetricService _awsEC2QueriesApplicationService = awsEC2QueriesApplicationService; _rbacApplicationService = rbacApplicationService; _requirementsMetricService = requirementsMetricService; + _catalogApplicationService = catalogApplicationService; + _catalogApiResourceFactory = catalogApiResourceFactory; } [HttpGet("")] @@ -297,6 +304,35 @@ public async Task GetCapabilityMembers(string id) return Ok(_apiResourceFactory.Convert(id, members)); } + [HttpGet("{id:required}/deployments")] + [ProducesResponseType(typeof(CatalogDeploymentsApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] + public async Task GetCapabilityDeployments(string id, CancellationToken cancellationToken) + { + if (!CapabilityId.TryParse(id, out var capabilityId)) + return NotFound( + new ProblemDetails + { + Title = "Capability not found", + Detail = $"Value \"{id}\" is not a valid capability id.", + Status = StatusCodes.Status404NotFound, + } + ); + + if (!await _capabilityRepository.Exists(capabilityId)) + return NotFound( + new ProblemDetails + { + Title = "Capability not found", + Detail = $"Capability \"{id}\" not found.", + Status = StatusCodes.Status404NotFound, + } + ); + + var result = await _catalogApplicationService.GetDeploymentsForCapability(capabilityId, cancellationToken); + return Ok(_catalogApiResourceFactory.ConvertDeployments(capabilityId, result)); + } + [HttpGet("{id:required}/awsaccount")] [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs index a57c6f21..cebd023d 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs @@ -41,6 +41,7 @@ public class CapabilityDetailsLinks public ResourceLink SelfAssessments { get; set; } public ResourceLink RequirementScore { get; set; } public ResourceLink ServicePrincipalMembers { get; set; } + public ResourceLink Deployments { get; set; } public CapabilityDetailsLinks( ResourceLink self, @@ -60,7 +61,8 @@ public CapabilityDetailsLinks( ResourceLink configurationLevel, ResourceLink selfAssessments, ResourceLink requirementScore, - ResourceLink servicePrincipalMembers + ResourceLink servicePrincipalMembers, + ResourceLink deployments ) { Self = self; @@ -81,6 +83,7 @@ ResourceLink servicePrincipalMembers SelfAssessments = selfAssessments; RequirementScore = requirementScore; ServicePrincipalMembers = servicePrincipalMembers; + Deployments = deployments; } } diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs new file mode 100644 index 00000000..3a17fd3c --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -0,0 +1,246 @@ +using Microsoft.AspNetCore.Mvc; +using SelfService.Application; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Api.Capabilities; +using SelfService.Infrastructure.Catalog; + +namespace SelfService.Infrastructure.Api.Catalog; + +/// +/// Maps merged catalog results ( over the wire DTOs) into the +/// SSU-facing API resources, attaching HATEOAS links via . +/// +public class CatalogApiResourceFactory +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly LinkGenerator _linkGenerator; + + public CatalogApiResourceFactory(IHttpContextAccessor httpContextAccessor, LinkGenerator linkGenerator) + { + _httpContextAccessor = httpContextAccessor; + _linkGenerator = linkGenerator; + } + + private HttpContext HttpContext => + _httpContextAccessor.HttpContext ?? throw new ApplicationException("Not in a http request context!"); + + private static string GetNameOf() + where TController : ControllerBase => typeof(TController).Name.Replace("Controller", ""); + + public CatalogDeploymentsApiResource ConvertDeployments( + CapabilityId capabilityId, + CatalogResult result + ) + { + return new CatalogDeploymentsApiResource + { + Data = result.Items.Select(MapApplication).ToList(), + Meta = MapMeta(result.Availability), + Links = new SelfLinks + { + Self = new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetCapabilityDeployments), + controller: GetNameOf(), + values: new { id = capabilityId.ToString() } + ) ?? "", + rel: "self", + allow: Allow.Get + ), + }, + }; + } + + public CatalogApplicationsApiResource ConvertApplications(CatalogResult result) + { + return new CatalogApplicationsApiResource + { + Data = result.Items.Select(MapApplication).ToList(), + Meta = MapMeta(result.Availability), + Links = new SelfLinks { Self = SelfLinkFor(nameof(CatalogController.GetApplications)) }, + }; + } + + public CatalogNamespacesApiResource ConvertNamespaces(CatalogResult result) + { + return new CatalogNamespacesApiResource + { + Data = result.Items.Select(MapNamespace).ToList(), + Meta = MapMeta(result.Availability), + Links = new SelfLinks { Self = SelfLinkFor(nameof(CatalogController.GetNamespaces)) }, + }; + } + + public CatalogDependenciesApiResource ConvertDependencies(CatalogResult result) + { + return new CatalogDependenciesApiResource + { + Data = result.Items.Select(MapDependency).ToList(), + Meta = MapMeta(result.Availability), + Links = new SelfLinks { Self = SelfLinkFor(nameof(CatalogController.GetDependencies)) }, + }; + } + + private ResourceLink SelfLinkFor(string action) => + new( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: action, + controller: GetNameOf() + ) ?? "", + rel: "self", + allow: Allow.Get + ); + + private static CatalogMetaApiResource MapMeta(CatalogAvailability availability) => + new() + { + CatalogAvailable = availability.CatalogAvailable, + ClustersQueried = availability.ClustersQueried, + ClustersFailed = availability.ClustersFailed, + }; + + private ApplicationApiResource MapApplication(ApplicationEntryDto app) => + new() + { + Cluster = app.Cluster, + Namespace = app.Namespace, + Name = app.Name, + Kind = app.Kind, + CapabilityId = app.CapabilityId, + CapabilityName = app.CapabilityName, + Replicas = app.Replicas, + ReadyReplicas = app.ReadyReplicas, + Containers = app + .Containers.Select(c => new ContainerApiResource + { + Name = c.Name, + Image = c.Image, + ImageTag = c.ImageTag, + }) + .ToList(), + RepoUrl = app.RepoUrl, + DeploymentSource = app.DeploymentSource is null + ? null + : new DeploymentSourceApiResource + { + Tool = app.DeploymentSource.Tool, + RepoUrl = app.DeploymentSource.RepoUrl, + Path = app.DeploymentSource.Path, + Revision = app.DeploymentSource.Revision, + AppName = app.DeploymentSource.AppName, + }, + Owner = app.Owner, + Contact = app.Contact, + Services = app.Services.Select(MapService).ToList(), + KafkaTopics = app + .KafkaTopics.Select(k => new KafkaTopicRefApiResource + { + Name = k.Name, + Direction = k.Direction, + Source = k.Source, + }) + .ToList(), + Databases = app + .Databases.Select(d => new DatabaseRefApiResource + { + System = d.System, + Name = d.Name, + Source = d.Source, + }) + .ToList(), + Links = new ApplicationApiResource.ApplicationLinks { Capability = CapabilityLink(app.CapabilityId) }, + }; + + private static ServiceApiResource MapService(ServiceRefDto svc) => + new() + { + Name = svc.Name, + Type = svc.Type, + ClusterIP = svc.ClusterIP, + Ports = svc + .Ports.Select(p => new ServicePortApiResource + { + Name = p.Name, + Port = p.Port, + TargetPort = p.TargetPort, + Protocol = p.Protocol, + }) + .ToList(), + ExternalHosts = svc.ExternalHosts.ToList(), + Routes = svc + .Routes.Select(r => new RouteApiResource + { + Name = r.Name, + Kind = r.Kind, + Hosts = r.Hosts.ToList(), + PathPrefixes = r.PathPrefixes.ToList(), + EntryPoints = r.EntryPoints.ToList(), + Tls = r.Tls, + }) + .ToList(), + ApiDocs = svc + .ApiDocs.Select(d => new ApiDocApiResource + { + Port = d.Port, + Path = d.Path, + Url = d.Url, + ExternallyAvailable = d.ExternallyAvailable, + ExternalUrl = d.ExternalUrl, + }) + .ToList(), + }; + + private NamespaceApiResource MapNamespace(NamespaceEntryDto ns) => + new() + { + Cluster = ns.Cluster, + Name = ns.Name, + CapabilityId = ns.CapabilityId, + CapabilityName = ns.CapabilityName, + AwsAccountId = ns.AwsAccountId, + ContextId = ns.ContextId, + CostCentre = ns.CostCentre, + Labels = ns.Labels, + Links = new NamespaceApiResource.NamespaceLinks { Capability = CapabilityLink(ns.CapabilityId) }, + }; + + private static DependencyApiResource MapDependency(DependencyEdgeDto edge) => + new() + { + Source = MapNode(edge.Source), + Target = MapNode(edge.Target), + Type = edge.Type, + Origin = edge.Origin, + Details = edge.Details, + }; + + private static DependencyNodeApiResource MapNode(DependencyNodeDto node) => + new() + { + Cluster = node.Cluster, + Namespace = node.Namespace, + Service = node.Service, + External = node.External, + }; + + private ResourceLink? CapabilityLink(string capabilityId) + { + if (!CapabilityId.TryParse(capabilityId, out var id)) + { + return null; + } + + return new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetCapabilityById), + controller: GetNameOf(), + values: new { id = id.ToString() } + ) ?? "", + rel: "related", + allow: Allow.Get + ); + } +} diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs new file mode 100644 index 00000000..15f2ecfc --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -0,0 +1,198 @@ +using System.Text.Json.Serialization; +using SelfService.Infrastructure.Api.Capabilities; + +namespace SelfService.Infrastructure.Api.Catalog; + +// Response DTOs for the catalog HTTP surface. These are the SSU-facing shapes — the raw +// ssu-catalog wire DTOs (Infrastructure/Catalog/CatalogDtos.cs) are never returned directly. +// Property names serialize to camelCase (ASP.NET Core web defaults); `_links` follows the +// HATEOAS convention used across the API. + +/// Availability summary surfaced in every catalog endpoint's meta envelope. +public class CatalogMetaApiResource +{ + public bool CatalogAvailable { get; init; } + public int ClustersQueried { get; init; } + public int ClustersFailed { get; init; } +} + +public class ContainerApiResource +{ + public string Name { get; init; } = ""; + public string Image { get; init; } = ""; + public string ImageTag { get; init; } = ""; +} + +public class ServicePortApiResource +{ + public string Name { get; init; } = ""; + public int Port { get; init; } + public string TargetPort { get; init; } = ""; + public string Protocol { get; init; } = ""; +} + +public class RouteApiResource +{ + public string Name { get; init; } = ""; + public string Kind { get; init; } = ""; + public IReadOnlyList Hosts { get; init; } = Array.Empty(); + public IReadOnlyList PathPrefixes { get; init; } = Array.Empty(); + public IReadOnlyList EntryPoints { get; init; } = Array.Empty(); + public bool Tls { get; init; } +} + +public class ApiDocApiResource +{ + public int Port { get; init; } + public string Path { get; init; } = ""; + public string Url { get; init; } = ""; + public bool ExternallyAvailable { get; init; } + public string ExternalUrl { get; init; } = ""; +} + +public class ServiceApiResource +{ + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public string ClusterIP { get; init; } = ""; + public IReadOnlyList Ports { get; init; } = Array.Empty(); + public IReadOnlyList ExternalHosts { get; init; } = Array.Empty(); + public IReadOnlyList Routes { get; init; } = Array.Empty(); + public IReadOnlyList ApiDocs { get; init; } = Array.Empty(); +} + +public class DeploymentSourceApiResource +{ + public string Tool { get; init; } = ""; + public string RepoUrl { get; init; } = ""; + public string Path { get; init; } = ""; + public string Revision { get; init; } = ""; + public string AppName { get; init; } = ""; +} + +public class KafkaTopicRefApiResource +{ + public string Name { get; init; } = ""; + public string Direction { get; init; } = ""; + public string Source { get; init; } = ""; +} + +public class DatabaseRefApiResource +{ + public string System { get; init; } = ""; + public string Name { get; init; } = ""; + public string Source { get; init; } = ""; +} + +/// A single workload (Deployment/StatefulSet/etc.) mapped from the catalog. +public class ApplicationApiResource +{ + public string Cluster { get; init; } = ""; + public string Namespace { get; init; } = ""; + public string Name { get; init; } = ""; + public string Kind { get; init; } = ""; + public string CapabilityId { get; init; } = ""; + public string? CapabilityName { get; init; } + + public int Replicas { get; init; } + public int ReadyReplicas { get; init; } + public IReadOnlyList Containers { get; init; } = Array.Empty(); + + public string RepoUrl { get; init; } = ""; + public DeploymentSourceApiResource? DeploymentSource { get; init; } + + public string Owner { get; init; } = ""; + public string Contact { get; init; } = ""; + + public IReadOnlyList Services { get; init; } = Array.Empty(); + public IReadOnlyList KafkaTopics { get; init; } = Array.Empty(); + public IReadOnlyList Databases { get; init; } = Array.Empty(); + + [JsonPropertyName("_links")] + public ApplicationLinks Links { get; init; } = new(); + + public class ApplicationLinks + { + public ResourceLink? Capability { get; init; } + } +} + +public class NamespaceApiResource +{ + public string Cluster { get; init; } = ""; + public string Name { get; init; } = ""; + public string CapabilityId { get; init; } = ""; + public string? CapabilityName { get; init; } + public string AwsAccountId { get; init; } = ""; + public string ContextId { get; init; } = ""; + public string CostCentre { get; init; } = ""; + public IReadOnlyDictionary? Labels { get; init; } + + [JsonPropertyName("_links")] + public NamespaceLinks Links { get; init; } = new(); + + public class NamespaceLinks + { + public ResourceLink? Capability { get; init; } + } +} + +public class DependencyNodeApiResource +{ + public string Cluster { get; init; } = ""; + public string Namespace { get; init; } = ""; + public string Service { get; init; } = ""; + public bool External { get; init; } +} + +public class DependencyApiResource +{ + public DependencyNodeApiResource Source { get; init; } = new(); + public DependencyNodeApiResource Target { get; init; } = new(); + public string Type { get; init; } = ""; + public string Origin { get; init; } = ""; + public string Details { get; init; } = ""; +} + +// ---- List envelopes: { data, meta, _links } ---- + +public class CatalogDeploymentsApiResource +{ + public IReadOnlyList Data { get; init; } = Array.Empty(); + public CatalogMetaApiResource Meta { get; init; } = new(); + + [JsonPropertyName("_links")] + public SelfLinks Links { get; init; } = new(); +} + +public class CatalogApplicationsApiResource +{ + public IReadOnlyList Data { get; init; } = Array.Empty(); + public CatalogMetaApiResource Meta { get; init; } = new(); + + [JsonPropertyName("_links")] + public SelfLinks Links { get; init; } = new(); +} + +public class CatalogNamespacesApiResource +{ + public IReadOnlyList Data { get; init; } = Array.Empty(); + public CatalogMetaApiResource Meta { get; init; } = new(); + + [JsonPropertyName("_links")] + public SelfLinks Links { get; init; } = new(); +} + +public class CatalogDependenciesApiResource +{ + public IReadOnlyList Data { get; init; } = Array.Empty(); + public CatalogMetaApiResource Meta { get; init; } = new(); + + [JsonPropertyName("_links")] + public SelfLinks Links { get; init; } = new(); +} + +public class SelfLinks +{ + public ResourceLink? Self { get; init; } +} diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs new file mode 100644 index 00000000..05effb34 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using SelfService.Application; + +namespace SelfService.Infrastructure.Api.Catalog; + +/// +/// Cross-cluster, capability-scoped view of the ssu-catalog application catalog. All endpoints +/// are open reads (authenticated, no special permission) and always return 200 — when the +/// upstream catalog is unreachable the payload carries empty data plus +/// meta.catalogAvailable = false (the unavailability contract). +/// +[Route("catalog")] +[Produces("application/json")] +[ApiController] +public class CatalogController : ControllerBase +{ + private readonly ICatalogApplicationService _catalogApplicationService; + private readonly CatalogApiResourceFactory _apiResourceFactory; + + public CatalogController( + ICatalogApplicationService catalogApplicationService, + CatalogApiResourceFactory apiResourceFactory + ) + { + _catalogApplicationService = catalogApplicationService; + _apiResourceFactory = apiResourceFactory; + } + + [HttpGet("applications")] + [ProducesResponseType(typeof(CatalogApplicationsApiResource), StatusCodes.Status200OK)] + public async Task GetApplications( + [FromQuery] string? capabilityId, + [FromQuery] string? @namespace, + [FromQuery] string? kind, + [FromQuery] string? q, + [FromQuery] bool? hasDocs, + CancellationToken cancellationToken + ) + { + var filters = new ApplicationFilters( + CapabilityId: capabilityId, + Namespace: @namespace, + Kind: kind, + Query: q, + HasDocs: hasDocs + ); + + var result = await _catalogApplicationService.ListApplications(filters, cancellationToken); + return Ok(_apiResourceFactory.ConvertApplications(result)); + } + + [HttpGet("namespaces")] + [ProducesResponseType(typeof(CatalogNamespacesApiResource), StatusCodes.Status200OK)] + public async Task GetNamespaces(CancellationToken cancellationToken) + { + var result = await _catalogApplicationService.ListNamespaces(cancellationToken); + return Ok(_apiResourceFactory.ConvertNamespaces(result)); + } + + [HttpGet("dependencies")] + [ProducesResponseType(typeof(CatalogDependenciesApiResource), StatusCodes.Status200OK)] + public async Task GetDependencies( + [FromQuery] string? @namespace, + [FromQuery] string? type, + CancellationToken cancellationToken + ) + { + var filters = new DependencyFilters(Namespace: @namespace, Type: type); + var result = await _catalogApplicationService.GetDependencies(filters, cancellationToken); + return Ok(_apiResourceFactory.ConvertDependencies(result)); + } +} diff --git a/src/SelfService/Infrastructure/Catalog/CatalogClient.cs b/src/SelfService/Infrastructure/Catalog/CatalogClient.cs new file mode 100644 index 00000000..3f186c47 --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogClient.cs @@ -0,0 +1,71 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace SelfService.Infrastructure.Catalog; + +public interface ICatalogClient +{ + /// + /// Fetches the full catalog snapshot from one cluster's ssu-catalog. Returns null on any + /// failure (network, non-success status, deserialization) — a failed cluster is skipped, + /// never fatal. + /// + Task GetCatalog(Uri clusterUrl, CancellationToken cancellationToken = default); +} + +public class CatalogClient : ICatalogClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly HttpClient _httpClient; + private readonly ICatalogTokenProvider _tokenProvider; + private readonly ILogger _logger; + + public CatalogClient(HttpClient httpClient, ICatalogTokenProvider tokenProvider, ILogger logger) + { + _httpClient = httpClient; + _tokenProvider = tokenProvider; + _logger = logger; + } + + public async Task GetCatalog(Uri clusterUrl, CancellationToken cancellationToken = default) + { + var requestUri = new Uri(clusterUrl, "/api/v1/catalog"); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + var token = await _tokenProvider.GetAccessToken(cancellationToken); + if (token is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "ssu-catalog at {ClusterUrl} returned {StatusCode}; skipping this cluster", + clusterUrl, + response.StatusCode + ); + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var envelope = await JsonSerializer.DeserializeAsync>( + stream, + JsonOptions, + cancellationToken + ); + + return envelope?.Data; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch catalog from {ClusterUrl}; skipping this cluster", clusterUrl); + return null; + } + } +} diff --git a/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs b/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs new file mode 100644 index 00000000..81cb935d --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs @@ -0,0 +1,63 @@ +namespace SelfService.Infrastructure.Catalog; + +/// A single per-cluster ssu-catalog endpoint from the registry. +public sealed record CatalogClusterEndpoint(string Cluster, Uri Url); + +/// +/// Catalog integration configuration. The per-cluster endpoint registry comes from +/// SS_CATALOG_ENDPOINTS ("cluster=url" CSV); the single shared downstream-API scope +/// comes from SS_CATALOG_SCOPE. An empty scope (local dev) disables token acquisition, +/// so no Authorization header is sent and OIDC-disabled ssu-catalog accepts the call. +/// +public sealed class CatalogConfig +{ + public IReadOnlyList Clusters { get; } + public string Scope { get; } + + /// True when a non-empty scope is configured, i.e. a bearer token must be acquired. + public bool TokenAcquisitionEnabled => !string.IsNullOrWhiteSpace(Scope); + + public CatalogConfig(IReadOnlyList clusters, string? scope) + { + Clusters = clusters; + Scope = scope ?? ""; + } + + /// + /// Parses a "cluster=url,cluster2=url2" CSV into the endpoint registry. Blank and + /// malformed entries (missing '=', empty cluster/url, non-absolute URL) are skipped. + /// + public static IReadOnlyList ParseEndpoints(string? raw) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(raw)) + { + return result; + } + + foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = part.IndexOf('='); + if (separator <= 0) + { + continue; // no key, skip + } + + var cluster = part[..separator].Trim(); + var url = part[(separator + 1)..].Trim(); + if (cluster.Length == 0 || url.Length == 0) + { + continue; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + continue; + } + + result.Add(new CatalogClusterEndpoint(cluster, uri)); + } + + return result; + } +} diff --git a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs new file mode 100644 index 00000000..719bb240 --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -0,0 +1,281 @@ +using System.Text.Json.Serialization; + +namespace SelfService.Infrastructure.Catalog; + +// DTOs mirroring ssu-catalog/internal/model/catalog.go. Deserialized with +// PropertyNameCaseInsensitive = true, so PascalCase properties map to the +// service's camelCase JSON without per-property attributes. +// +// Collection properties use null-coalescing setters. ssu-catalog is Go, where a +// nil slice marshals to JSON `null` (not `[]`) unless tagged omitempty. A property +// initializer (`= new()`) only applies when the JSON key is ABSENT; an explicit +// `null` value overwrites it. The coalescing setter guarantees these are never null +// after deserialization, so the mapper and merge/join code can enumerate freely. + +/// Envelope returned by every ssu-catalog endpoint: { data, meta }. +public sealed class CatalogEnvelope +{ + public T? Data { get; set; } + public CatalogEnvelopeMeta? Meta { get; set; } +} + +public sealed class CatalogEnvelopeMeta +{ + public DateTime CollectedAt { get; set; } + public string Cluster { get; set; } = ""; +} + +/// Full per-cluster catalog snapshot (GET /api/v1/catalog). +public sealed class CatalogSnapshotDto +{ + private List _applications = new(); + private List _namespaces = new(); + private List _dependencies = new(); + + public string Cluster { get; set; } = ""; + public List Applications + { + get => _applications; + set => _applications = value ?? new(); + } + public List Namespaces + { + get => _namespaces; + set => _namespaces = value ?? new(); + } + public List Dependencies + { + get => _dependencies; + set => _dependencies = value ?? new(); + } + public DateTime CollectedAt { get; set; } + public CatalogStatsDto? Stats { get; set; } +} + +public sealed class CatalogStatsDto +{ + public int TotalApplications { get; set; } + public int CapabilityOwnedApplications { get; set; } + public int ApplicationsWithDocs { get; set; } + public int ApplicationsWithDeploySource { get; set; } + public int TotalDependencies { get; set; } + public long CollectionDurationMs { get; set; } +} + +public sealed class NamespaceEntryDto +{ + public string Cluster { get; set; } = ""; + public string Name { get; set; } = ""; + public string CapabilityId { get; set; } = ""; + public string AwsAccountId { get; set; } = ""; + public string ContextId { get; set; } = ""; + public string CostCentre { get; set; } = ""; + public Dictionary? Labels { get; set; } + + /// Authoritative capability name, joined SSU-side (not part of the wire model). + [JsonIgnore] + public string? CapabilityName { get; set; } +} + +public sealed class ApplicationEntryDto +{ + private List _containers = new(); + private List _services = new(); + private List _kafkaTopics = new(); + private List _databases = new(); + + // Identity / join keys + public string Cluster { get; set; } = ""; + public string Namespace { get; set; } = ""; + public string Name { get; set; } = ""; + public string Kind { get; set; } = ""; + public string CapabilityId { get; set; } = ""; + + // Workload runtime + public int Replicas { get; set; } + public int ReadyReplicas { get; set; } + public List Containers + { + get => _containers; + set => _containers = value ?? new(); + } + + // Deployment / repository (GitOps-derived) + public string RepoUrl { get; set; } = ""; + public DeploymentSourceDto? DeploymentSource { get; set; } + + // Best-effort owner (may be empty) + public string Owner { get; set; } = ""; + public string Contact { get; set; } = ""; + + // Attached networking / API surface + public List Services + { + get => _services; + set => _services = value ?? new(); + } + + // Observed runtime overlay (best-effort) + public List KafkaTopics + { + get => _kafkaTopics; + set => _kafkaTopics = value ?? new(); + } + public List Databases + { + get => _databases; + set => _databases = value ?? new(); + } + + public Dictionary? Labels { get; set; } + public Dictionary? Annotations { get; set; } + + /// Authoritative capability name, joined SSU-side (not part of the wire model). + [JsonIgnore] + public string? CapabilityName { get; set; } +} + +public sealed class ServiceRefDto +{ + private List _ports = new(); + private List _externalHosts = new(); + private List _routes = new(); + private List _apiDocs = new(); + + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public string ClusterIP { get; set; } = ""; + public List Ports + { + get => _ports; + set => _ports = value ?? new(); + } + public List ExternalHosts + { + get => _externalHosts; + set => _externalHosts = value ?? new(); + } + public List Routes + { + get => _routes; + set => _routes = value ?? new(); + } + public List ApiDocs + { + get => _apiDocs; + set => _apiDocs = value ?? new(); + } +} + +public sealed class RouteRefDto +{ + private List _hosts = new(); + private List _pathPrefixes = new(); + private List _entryPoints = new(); + + public string Name { get; set; } = ""; + public string Kind { get; set; } = ""; + public List Hosts + { + get => _hosts; + set => _hosts = value ?? new(); + } + public List PathPrefixes + { + get => _pathPrefixes; + set => _pathPrefixes = value ?? new(); + } + public List EntryPoints + { + get => _entryPoints; + set => _entryPoints = value ?? new(); + } + public bool Tls { get; set; } +} + +public sealed class DeploymentSourceDto +{ + public string Tool { get; set; } = ""; + public string RepoUrl { get; set; } = ""; + public string Path { get; set; } = ""; + public string Revision { get; set; } = ""; + public string AppName { get; set; } = ""; +} + +public sealed class ContainerInfoDto +{ + private List _ports = new(); + + public string Name { get; set; } = ""; + public string Image { get; set; } = ""; + public string ImageTag { get; set; } = ""; + public List Ports + { + get => _ports; + set => _ports = value ?? new(); + } + public ResourceInfoDto? Resources { get; set; } +} + +public sealed class ContainerPortDto +{ + public string Name { get; set; } = ""; + public int ContainerPort { get; set; } + public string Protocol { get; set; } = ""; +} + +public sealed class ResourceInfoDto +{ + public string RequestsCpu { get; set; } = ""; + public string RequestsMemory { get; set; } = ""; + public string LimitsCpu { get; set; } = ""; + public string LimitsMemory { get; set; } = ""; +} + +public sealed class ServicePortDto +{ + public string Name { get; set; } = ""; + public int Port { get; set; } + public string TargetPort { get; set; } = ""; + public string Protocol { get; set; } = ""; +} + +public sealed class ApiDocInfoDto +{ + public int Port { get; set; } + public string Path { get; set; } = ""; + public string Url { get; set; } = ""; + public bool ExternallyAvailable { get; set; } + public string ExternalUrl { get; set; } = ""; +} + +public sealed class KafkaTopicRefDto +{ + public string Name { get; set; } = ""; + public string Direction { get; set; } = ""; + public string Source { get; set; } = ""; +} + +public sealed class DatabaseRefDto +{ + public string System { get; set; } = ""; + public string Name { get; set; } = ""; + public string Source { get; set; } = ""; +} + +public sealed class DependencyEdgeDto +{ + public DependencyNodeDto Source { get; set; } = new(); + public DependencyNodeDto Target { get; set; } = new(); + public string Type { get; set; } = ""; + public string Origin { get; set; } = ""; + public string Details { get; set; } = ""; +} + +public sealed class DependencyNodeDto +{ + public string Cluster { get; set; } = ""; + public string Namespace { get; set; } = ""; + public string Service { get; set; } = ""; + public bool External { get; set; } +} diff --git a/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs b/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs new file mode 100644 index 00000000..c8aa3841 --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs @@ -0,0 +1,56 @@ +using Microsoft.Identity.Web; + +namespace SelfService.Infrastructure.Catalog; + +public interface ICatalogTokenProvider +{ + /// + /// Acquires an app-only access token for the configured catalog scope, or null when no + /// scope is configured (local dev) — in which case the caller omits the Authorization header. + /// + Task GetAccessToken(CancellationToken cancellationToken = default); +} + +/// +/// Thin wrapper over ITokenAcquisition.GetAccessTokenForAppAsync. selfservice-api acquires the +/// token as its own service principal; one shared app token is used across all clusters (only the +/// endpoint registry is per-cluster). +/// +public class CatalogTokenProvider : ICatalogTokenProvider +{ + private readonly CatalogConfig _config; + private readonly ITokenAcquisition _tokenAcquisition; + private readonly ILogger _logger; + + public CatalogTokenProvider( + CatalogConfig config, + ITokenAcquisition tokenAcquisition, + ILogger logger + ) + { + _config = config; + _tokenAcquisition = tokenAcquisition; + _logger = logger; + } + + public async Task GetAccessToken(CancellationToken cancellationToken = default) + { + if (!_config.TokenAcquisitionEnabled) + { + // Local dev / unconfigured: no bearer token — ssu-catalog runs OIDC-disabled. + return null; + } + + try + { + return await _tokenAcquisition.GetAccessTokenForAppAsync(_config.Scope); + } + catch (Exception ex) + { + // Fail soft: a missing token means a 401 downstream, which the client treats as a + // per-cluster failure (meta.catalogAvailable reflects it). Never throw here. + _logger.LogError(ex, "Failed to acquire catalog access token for scope {Scope}", _config.Scope); + return null; + } + } +} diff --git a/src/SelfService/Logging.cs b/src/SelfService/Logging.cs index 0c942511..08dc4413 100644 --- a/src/SelfService/Logging.cs +++ b/src/SelfService/Logging.cs @@ -21,6 +21,10 @@ public static void AddLogging(this WebApplicationBuilder builder) .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.IdentityModel", LogEventLevel.Warning) + // MSAL routes its (Info-level) logging through Microsoft.Identity.Web's + // ITokenAcquisition logger — the "MSAL 4.x … .NET … Darwin …" banner spam + // on every token acquisition (e.g. the catalog token provider). Keep warnings. + .MinimumLevel.Override("Microsoft.Identity.Web", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) .MinimumLevel.Override( From f6be5114dc90eff1465ebdf318c05415baf8a5eb Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Wed, 1 Jul 2026 14:23:47 +0200 Subject: [PATCH 2/7] feat(catalog): add application metadata, repository URLs, and timestamps --- .../Catalog/TestCatalogClient.cs | 132 ++++++++++++++++++ .../Application/CatalogApplicationService.cs | 25 +++- .../Api/Catalog/CatalogApiResourceFactory.cs | 13 +- .../Api/Catalog/CatalogApiResources.cs | 29 +++- .../Infrastructure/Catalog/CatalogDtos.cs | 32 ++++- 5 files changed, 224 insertions(+), 7 deletions(-) diff --git a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs index 5ad73679..da36d0bf 100644 --- a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs +++ b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs @@ -75,6 +75,138 @@ public async Task GetCatalog_unwraps_envelope_and_deserializes_camelCase() Assert.Equal("/swagger", Assert.Single(service.ApiDocs).Path); } + [Fact] + public async Task GetCatalog_deserializes_author_declared_metadata() + { + const string json = """ + { + "data": { + "cluster": "local", + "applications": [ + { + "namespace": "team-alpha-abcde", + "name": "api", + "kind": "Deployment", + "capabilityId": "team-alpha-abcde", + "metadata": { + "description": "Handles billing", + "links": [ + { "label": "dashboard", "url": "https://grafana/d/api" }, + { "label": "runbook", "url": "https://wiki/runbooks/api" } + ] + } + } + ], + "namespaces": [], + "dependencies": [], + "collectedAt": "2026-01-01T00:00:00Z" + }, + "meta": { "collectedAt": "2026-01-01T00:00:00Z", "cluster": "local" } + } + """; + + var client = new CatalogClient( + MockHttp(HttpStatusCode.OK, json), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + var app = Assert.Single(snapshot!.Applications); + Assert.NotNull(app.Metadata); + Assert.Equal("Handles billing", app.Metadata!.Description); + Assert.Equal(2, app.Metadata.Links.Count); + Assert.Equal("dashboard", app.Metadata.Links[0].Label); + Assert.Equal("https://grafana/d/api", app.Metadata.Links[0].Url); + } + + [Fact] + public async Task GetCatalog_deserializes_all_repo_urls_and_null_coalesces() + { + // ssu-catalog emits repoUrls[] carrying the discovered GitOps repo *and* + // any author-declared dfds.cloud/repo, deduped & discovered-first. The + // whole array must survive so the declared repo isn't dropped when a + // GitOps source is found. A workload without repos marshals as null. + const string json = """ + { + "data": { + "cluster": "local", + "applications": [ + { + "namespace": "ns-a", "name": "gitops-plus-declared", "kind": "Deployment", "capabilityId": "ns-a", + "repoUrls": [ "https://github.com/dfds/ssu-apps", "https://github.com/dfds/api" ], + "deploymentSource": { "tool": "argocd", "repoUrl": "https://github.com/dfds/ssu-apps" } + }, + { "namespace": "ns-b", "name": "no-repos", "kind": "Deployment", "capabilityId": "ns-b", "repoUrls": null } + ], + "namespaces": [], "dependencies": [], "collectedAt": "2026-01-01T00:00:00Z" + }, + "meta": { "collectedAt": "2026-01-01T00:00:00Z", "cluster": "local" } + } + """; + + var client = new CatalogClient( + MockHttp(HttpStatusCode.OK, json), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + var both = snapshot!.Applications.Single(a => a.Name == "gitops-plus-declared"); + Assert.Equal( + new[] { "https://github.com/dfds/ssu-apps", "https://github.com/dfds/api" }, + both.RepoUrls + ); + Assert.Equal("https://github.com/dfds/ssu-apps", both.DeploymentSource!.RepoUrl); + + var none = snapshot.Applications.Single(a => a.Name == "no-repos"); + Assert.NotNull(none.RepoUrls); + Assert.Empty(none.RepoUrls); + } + + [Fact] + public async Task GetCatalog_metadata_absent_stays_null_and_null_links_coalesce() + { + // metadata absent on the first app; present-with-null-links on the second + // (Go marshals a nil slice as JSON null). The coalescing setter must keep + // Links non-null so downstream mapping/enumeration is safe. + const string json = """ + { + "data": { + "cluster": "local", + "applications": [ + { "namespace": "ns-a", "name": "no-meta", "kind": "Deployment", "capabilityId": "ns-a" }, + { + "namespace": "ns-b", "name": "desc-only", "kind": "Deployment", "capabilityId": "ns-b", + "metadata": { "description": "Just a description", "links": null } + } + ], + "namespaces": [], "dependencies": [], "collectedAt": "2026-01-01T00:00:00Z" + }, + "meta": { "collectedAt": "2026-01-01T00:00:00Z", "cluster": "local" } + } + """; + + var client = new CatalogClient( + MockHttp(HttpStatusCode.OK, json), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + var noMeta = snapshot!.Applications.Single(a => a.Name == "no-meta"); + Assert.Null(noMeta.Metadata); + + var descOnly = snapshot.Applications.Single(a => a.Name == "desc-only"); + Assert.NotNull(descOnly.Metadata); + Assert.Equal("Just a description", descOnly.Metadata!.Description); + Assert.NotNull(descOnly.Metadata.Links); + Assert.Empty(descOnly.Metadata.Links); + } + [Fact] public async Task GetCatalog_returns_null_on_non_success_status() { diff --git a/src/SelfService/Application/CatalogApplicationService.cs b/src/SelfService/Application/CatalogApplicationService.cs index b43225e9..5fd3b6df 100644 --- a/src/SelfService/Application/CatalogApplicationService.cs +++ b/src/SelfService/Application/CatalogApplicationService.cs @@ -5,7 +5,13 @@ namespace SelfService.Application; /// Availability summary surfaced in every catalog endpoint's meta envelope. -public sealed record CatalogAvailability(bool CatalogAvailable, int ClustersQueried, int ClustersFailed); +public sealed record CatalogAvailability( + bool CatalogAvailable, + int ClustersQueried, + int ClustersFailed, + DateTime? CollectedAt, + DateTime? PublishedAt +); /// A query result: the matching items plus the cross-cluster availability summary. public sealed record CatalogResult(IReadOnlyList Items, CatalogAvailability Availability); @@ -189,6 +195,8 @@ private async Task FetchAndMerge(CancellationToken cancellationTo var namespaces = new List(); var dependencies = new List(); var clustersFailed = 0; + var collectedTimes = new List(); + var publishedTimes = new List(); foreach (var (endpoint, snapshot) in results) { @@ -211,12 +219,25 @@ private async Task FetchAndMerge(CancellationToken cancellationTo apps.AddRange(snapshot.Applications); namespaces.AddRange(snapshot.Namespaces); dependencies.AddRange(snapshot.Dependencies); + // Skip zero-value timestamps: an older ssu-catalog that doesn't emit the field + // deserializes to default(DateTime) (year 1), which must not poison the min(). + if (snapshot.CollectedAt.Year > 1) + { + collectedTimes.Add(snapshot.CollectedAt); + } + if (snapshot.PublishedAt.Year > 1) + { + publishedTimes.Add(snapshot.PublishedAt); + } } var availability = new CatalogAvailability( CatalogAvailable: registry.Count > 0 && clustersFailed < registry.Count, ClustersQueried: registry.Count, - ClustersFailed: clustersFailed + ClustersFailed: clustersFailed, + // Stalest snapshot bounds the freshness of the merged view; null if all clusters failed. + CollectedAt: collectedTimes.Count > 0 ? collectedTimes.Min() : null, + PublishedAt: publishedTimes.Count > 0 ? publishedTimes.Min() : null ); // Join once: resolve each distinct capabilityId to a real Capability, keeping only owned diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs index 3a17fd3c..7bbb02e9 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -99,6 +99,8 @@ private static CatalogMetaApiResource MapMeta(CatalogAvailability availability) CatalogAvailable = availability.CatalogAvailable, ClustersQueried = availability.ClustersQueried, ClustersFailed = availability.ClustersFailed, + CollectedAt = availability.CollectedAt, + PublishedAt = availability.PublishedAt, }; private ApplicationApiResource MapApplication(ApplicationEntryDto app) => @@ -120,7 +122,7 @@ private ApplicationApiResource MapApplication(ApplicationEntryDto app) => ImageTag = c.ImageTag, }) .ToList(), - RepoUrl = app.RepoUrl, + RepoUrls = app.RepoUrls, DeploymentSource = app.DeploymentSource is null ? null : new DeploymentSourceApiResource @@ -131,6 +133,15 @@ private ApplicationApiResource MapApplication(ApplicationEntryDto app) => Revision = app.DeploymentSource.Revision, AppName = app.DeploymentSource.AppName, }, + Metadata = app.Metadata is null + ? null + : new AppMetadataApiResource + { + Description = app.Metadata.Description, + Links = app + .Metadata.Links.Select(l => new LinkRefApiResource { Label = l.Label, Url = l.Url }) + .ToList(), + }, Owner = app.Owner, Contact = app.Contact, Services = app.Services.Select(MapService).ToList(), diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs index 15f2ecfc..e8b29a0f 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -14,6 +14,19 @@ public class CatalogMetaApiResource public bool CatalogAvailable { get; init; } public int ClustersQueried { get; init; } public int ClustersFailed { get; init; } + + /// + /// When ssu-catalog began its scan (min across queried clusters). Null when no cluster returned + /// a snapshot. Informational — for the "last updated" age prefer . + /// + public DateTime? CollectedAt { get; init; } + + /// + /// When ssu-catalog finished assembling the snapshot — i.e. when the data became current (min + /// across queried clusters). The portal renders this as the "last updated" age. Null when no + /// cluster returned a snapshot. + /// + public DateTime? PublishedAt { get; init; } } public class ContainerApiResource @@ -70,6 +83,18 @@ public class DeploymentSourceApiResource public string AppName { get; init; } = ""; } +public class AppMetadataApiResource +{ + public string Description { get; init; } = ""; + public IReadOnlyList Links { get; init; } = Array.Empty(); +} + +public class LinkRefApiResource +{ + public string Label { get; init; } = ""; + public string Url { get; init; } = ""; +} + public class KafkaTopicRefApiResource { public string Name { get; init; } = ""; @@ -98,9 +123,11 @@ public class ApplicationApiResource public int ReadyReplicas { get; init; } public IReadOnlyList Containers { get; init; } = Array.Empty(); - public string RepoUrl { get; init; } = ""; + public IReadOnlyList RepoUrls { get; init; } = Array.Empty(); public DeploymentSourceApiResource? DeploymentSource { get; init; } + public AppMetadataApiResource? Metadata { get; init; } + public string Owner { get; init; } = ""; public string Contact { get; init; } = ""; diff --git a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs index 719bb240..d38ca9cd 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -49,6 +49,7 @@ public List Dependencies set => _dependencies = value ?? new(); } public DateTime CollectedAt { get; set; } + public DateTime PublishedAt { get; set; } public CatalogStatsDto? Stats { get; set; } } @@ -83,6 +84,7 @@ public sealed class ApplicationEntryDto private List _services = new(); private List _kafkaTopics = new(); private List _databases = new(); + private List _repoUrls = new(); // Identity / join keys public string Cluster { get; set; } = ""; @@ -99,10 +101,15 @@ public List Containers get => _containers; set => _containers = value ?? new(); } - - // Deployment / repository (GitOps-derived) - public string RepoUrl { get; set; } = ""; + + public List RepoUrls + { + get => _repoUrls; + set => _repoUrls = value ?? new(); + } public DeploymentSourceDto? DeploymentSource { get; set; } + + public AppMetadataDto? Metadata { get; set; } // Best-effort owner (may be empty) public string Owner { get; set; } = ""; @@ -202,6 +209,25 @@ public sealed class DeploymentSourceDto public string AppName { get; set; } = ""; } +/// Author-declared workload metadata (dfds.cloud/description + dfds.cloud/link.*). +public sealed class AppMetadataDto +{ + private List _links = new(); + + public string Description { get; set; } = ""; + public List Links + { + get => _links; + set => _links = value ?? new(); + } +} + +public sealed class LinkRefDto +{ + public string Label { get; set; } = ""; + public string Url { get; set; } = ""; +} + public sealed class ContainerInfoDto { private List _ports = new(); From a47406b378b4b2c15339055ddd278b6cf607de35 Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Sat, 4 Jul 2026 20:09:33 +0200 Subject: [PATCH 3/7] fix(catalog): keep inbound dependency edges for owned namespaces --- .../TestCatalogApplicationService.cs | 59 +++++++++++++++++++ .../Application/CatalogApplicationService.cs | 13 +++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs index 59a2a5e7..38ad6ea4 100644 --- a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs +++ b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs @@ -103,6 +103,65 @@ public async Task Merge_keeps_only_capability_owned_apps_and_joins_name() Assert.Equal(0, result.Availability.ClustersFailed); } + [Fact] + public async Task GetDependencies_keeps_edges_owned_on_either_end() + { + var capability = A.Capability.WithId(CapabilityId.Parse("team-alpha-abcde")).WithName("Team Alpha").Build(); + + // Nodes carry ssu-catalog's payload cluster ("local" here == the registry key); + // only apps/namespaces get the registry cluster re-stamped on merge. + DependencyNodeDto Owned() => + new() + { + Cluster = "local", + Namespace = "team-alpha-abcde", + Service = "api", + External = false, + }; + DependencyNodeDto External(string service) => new() { Service = service, External = true }; + + var snapshot = new CatalogSnapshotDto + { + Applications = + { + new ApplicationEntryDto + { + Namespace = "team-alpha-abcde", + Name = "api", + Kind = "Deployment", + CapabilityId = "team-alpha-abcde", + }, + }, + Dependencies = + { + // Outbound: owned app → external. Kept (source owned). + new DependencyEdgeDto { Source = Owned(), Target = External("rds.example.com") }, + // Inbound: another workload → owned app. Kept (target owned) — this is the fix; + // a source-only filter would drop it and hide "who connects to me". + new DependencyEdgeDto { Source = External("ssu-catalog"), Target = Owned() }, + // Neither end owned. Dropped. + new DependencyEdgeDto { Source = External("x"), Target = External("y") }, + }, + }; + + var catalogClient = new Mock(); + catalogClient.Setup(x => x.GetCatalog(It.IsAny(), It.IsAny())).ReturnsAsync(snapshot); + + var capabilityRepository = new Mock(); + capabilityRepository + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(new[] { capability }); + + var service = BuildService(SingleCluster(), catalogClient.Object, capabilityRepository.Object); + + var result = await service.GetDependencies(new DependencyFilters()); + + Assert.Equal(2, result.Items.Count); + Assert.Contains(result.Items, d => d.Source.Service == "api" && d.Target.Service == "rds.example.com"); + Assert.Contains(result.Items, d => d.Source.Service == "ssu-catalog" && d.Target.Service == "api"); + Assert.DoesNotContain(result.Items, d => d.Source.Service == "x"); + } + [Fact] public async Task GetDeploymentsForCapability_filters_by_capability_id() { diff --git a/src/SelfService/Application/CatalogApplicationService.cs b/src/SelfService/Application/CatalogApplicationService.cs index 5fd3b6df..f18f2997 100644 --- a/src/SelfService/Application/CatalogApplicationService.cs +++ b/src/SelfService/Application/CatalogApplicationService.cs @@ -252,14 +252,21 @@ private async Task FetchAndMerge(CancellationToken cancellationTo .Where(n => TryAttachCapability(n.CapabilityId, capabilityNames, name => n.CapabilityName = name)) .ToList(); - // Keep dependency edges whose source namespace is capability-owned (source is the - // in-cluster app; targets may be external). Best-effort overlay. + // Keep dependency edges touching a capability-owned namespace on EITHER end. + // Source-owned keeps a workload's OUTBOUND edges (the in-cluster app calling an + // external/other target); target-owned keeps its INBOUND edges (another workload + // — e.g. ssu-catalog probing it, whose source is external/unowned — connecting to + // the owned app). A source-only filter drops every inbound edge, so the portal's + // connections graph shows outbound-only. Best-effort overlay. var ownedNamespaceKeys = ownedApps .Select(a => (a.Cluster, a.Namespace)) .Concat(ownedNamespaces.Select(n => (n.Cluster, n.Name))) .ToHashSet(); var ownedDependencies = dependencies - .Where(d => ownedNamespaceKeys.Contains((d.Source.Cluster, d.Source.Namespace))) + .Where(d => + ownedNamespaceKeys.Contains((d.Source.Cluster, d.Source.Namespace)) + || ownedNamespaceKeys.Contains((d.Target.Cluster, d.Target.Namespace)) + ) .ToList(); _logger.LogDebug( From 1dee7848c6ea41434debbd149930c6c10abf6128 Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Sun, 5 Jul 2026 11:47:52 +0200 Subject: [PATCH 4/7] feat(catalog): add request rate and error rate metrics to applications --- .../Api/Catalog/CatalogApiResourceFactory.cs | 3 +++ .../Infrastructure/Api/Catalog/CatalogApiResources.cs | 9 +++++++++ src/SelfService/Infrastructure/Catalog/CatalogDtos.cs | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs index 7bbb02e9..2edc851f 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -161,6 +161,9 @@ private ApplicationApiResource MapApplication(ApplicationEntryDto app) => Source = d.Source, }) .ToList(), + Runtime = string.IsNullOrEmpty(app.Runtime) ? null : app.Runtime, + RequestRate = app.RequestRate, + ErrorRate = app.ErrorRate, Links = new ApplicationApiResource.ApplicationLinks { Capability = CapabilityLink(app.CapabilityId) }, }; diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs index e8b29a0f..1892b93b 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -135,6 +135,15 @@ public class ApplicationApiResource public IReadOnlyList KafkaTopics { get; init; } = Array.Empty(); public IReadOnlyList Databases { get; init; } = Array.Empty(); + /// Beyla's detected runtime/language; null when not detected (omitted from responses). + public string? Runtime { get; init; } + + /// Inbound HTTP throughput (req/s), Beyla-observed; null when no inbound HTTP seen. + public double? RequestRate { get; init; } + + /// 5xx share of inbound traffic (0..1); meaningful only with RequestRate > 0. + public double? ErrorRate { get; init; } + [JsonPropertyName("_links")] public ApplicationLinks Links { get; init; } = new(); diff --git a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs index d38ca9cd..71d9a388 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -137,6 +137,15 @@ public List Databases public Dictionary? Labels { get; set; } public Dictionary? Annotations { get; set; } + /// Beyla's detected runtime/language (e.g. "go", "dotnet"); empty/absent when undetected. + public string? Runtime { get; set; } + + /// Inbound HTTP throughput (req/s), Beyla-observed; null/absent when no inbound HTTP seen. + public double? RequestRate { get; set; } + + /// 5xx share of inbound traffic (0..1); meaningful only with RequestRate > 0. + public double? ErrorRate { get; set; } + /// Authoritative capability name, joined SSU-side (not part of the wire model). [JsonIgnore] public string? CapabilityName { get; set; } From 0c3bd06c6abd019bd8d3052e18c7fda032a3d085 Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Thu, 9 Jul 2026 16:38:04 +0200 Subject: [PATCH 5/7] feat(catalog): add ingress reachability overlay support --- .../Catalog/TestCatalogClient.cs | 69 +++++++++++++++++++ .../Api/Catalog/CatalogApiResourceFactory.cs | 12 ++++ .../Api/Catalog/CatalogApiResources.cs | 12 ++++ .../Infrastructure/Catalog/CatalogDtos.cs | 20 ++++++ 4 files changed, 113 insertions(+) diff --git a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs index da36d0bf..7f787426 100644 --- a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs +++ b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs @@ -207,6 +207,75 @@ public async Task GetCatalog_metadata_absent_stays_null_and_null_links_coalesce( Assert.Empty(descOnly.Metadata.Links); } + [Fact] + public async Task GetCatalog_deserializes_reachability_and_null_coalesces() + { + // First service carries a reachability overlay; the second omits it entirely + // (Go's omitempty drops the key). The coalescing setter must leave Reachability + // non-null and empty so downstream mapping/enumeration is safe. + const string json = """ + { + "data": { + "cluster": "local", + "applications": [ + { + "namespace": "team-alpha-abcde", + "name": "api", + "kind": "Deployment", + "capabilityId": "team-alpha-abcde", + "services": [ + { + "name": "svc-a", + "externalHosts": [ "api.example.com" ], + "reachability": [ + { + "host": "api.example.com", + "url": "https://api.example.com/", + "status": "reachable", + "statusCode": 200, + "expected": "200", + "reason": "", + "checkedAt": "2026-07-10T12:00:00Z" + } + ] + }, + { "name": "svc-b", "reachability": null } + ] + } + ], + "namespaces": [], + "dependencies": [], + "collectedAt": "2026-01-01T00:00:00Z" + }, + "meta": { "collectedAt": "2026-01-01T00:00:00Z", "cluster": "local" } + } + """; + + var client = new CatalogClient( + MockHttp(HttpStatusCode.OK, json), + NoToken(), + NullLogger.Instance + ); + + var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); + + Assert.NotNull(snapshot); + var app = Assert.Single(snapshot!.Applications); + Assert.Equal(2, app.Services.Count); + + var svcA = app.Services[0]; + var reach = Assert.Single(svcA.Reachability); + Assert.Equal("api.example.com", reach.Host); + Assert.Equal("reachable", reach.Status); + Assert.Equal(200, reach.StatusCode); + Assert.Equal("200", reach.Expected); + + // Absent/null reachability coalesces to a non-null empty list. + var svcB = app.Services[1]; + Assert.NotNull(svcB.Reachability); + Assert.Empty(svcB.Reachability); + } + [Fact] public async Task GetCatalog_returns_null_on_non_success_status() { diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs index 2edc851f..4d94cbeb 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -204,6 +204,18 @@ private static ServiceApiResource MapService(ServiceRefDto svc) => ExternalUrl = d.ExternalUrl, }) .ToList(), + Reachability = svc + .Reachability.Select(r => new ReachabilityApiResource + { + Host = r.Host, + Url = r.Url, + Status = r.Status, + StatusCode = r.StatusCode, + Expected = r.Expected, + Reason = r.Reason, + CheckedAt = r.CheckedAt, + }) + .ToList(), }; private NamespaceApiResource MapNamespace(NamespaceEntryDto ns) => diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs index 1892b93b..ac7f91c3 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -63,6 +63,17 @@ public class ApiDocApiResource public string ExternalUrl { get; init; } = ""; } +public class ReachabilityApiResource +{ + public string Host { get; init; } = ""; + public string Url { get; init; } = ""; + public string Status { get; init; } = ""; + public int StatusCode { get; init; } + public string Expected { get; init; } = ""; + public string Reason { get; init; } = ""; + public DateTime CheckedAt { get; init; } +} + public class ServiceApiResource { public string Name { get; init; } = ""; @@ -72,6 +83,7 @@ public class ServiceApiResource public IReadOnlyList ExternalHosts { get; init; } = Array.Empty(); public IReadOnlyList Routes { get; init; } = Array.Empty(); public IReadOnlyList ApiDocs { get; init; } = Array.Empty(); + public IReadOnlyList Reachability { get; init; } = Array.Empty(); } public class DeploymentSourceApiResource diff --git a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs index 71d9a388..99a68752 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -157,6 +157,7 @@ public sealed class ServiceRefDto private List _externalHosts = new(); private List _routes = new(); private List _apiDocs = new(); + private List _reachability = new(); public string Name { get; set; } = ""; public string Type { get; set; } = ""; @@ -181,6 +182,13 @@ public List ApiDocs get => _apiDocs; set => _apiDocs = value ?? new(); } + // Serve-time reachability overlay from ssu-catalog; omitempty on the Go side, + // so it arrives absent (→ empty) for hosts with no verdict. + public List Reachability + { + get => _reachability; + set => _reachability = value ?? new(); + } } public sealed class RouteRefDto @@ -284,6 +292,18 @@ public sealed class ApiDocInfoDto public string ExternalUrl { get; set; } = ""; } +/// Active ingress-reachability verdict per exposed host (ssu-catalog serve-time overlay). +public sealed class ReachabilityResultDto +{ + public string Host { get; set; } = ""; + public string Url { get; set; } = ""; + public string Status { get; set; } = ""; // "reachable" | "unreachable" | "unknown" + public int StatusCode { get; set; } + public string Expected { get; set; } = ""; + public string Reason { get; set; } = ""; + public DateTime CheckedAt { get; set; } +} + public sealed class KafkaTopicRefDto { public string Name { get; set; } = ""; From f6eed4145a7d0b875198a2eed75d98325a1754cb Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Mon, 13 Jul 2026 22:15:29 +0200 Subject: [PATCH 6/7] chore(catalog): update mise config and remove unused code --- mise.toml | 5 ++- .../TestCatalogApplicationService.cs | 4 --- .../Application/CatalogApplicationService.cs | 33 ++----------------- src/SelfService/Configuration/Domain.cs | 3 -- .../Configuration/Observability.cs | 2 +- src/SelfService/Configuration/Security.cs | 6 ---- .../Infrastructure/Api/ApiResourceFactory.cs | 1 - .../Api/Catalog/CatalogApiResourceFactory.cs | 4 --- .../Api/Catalog/CatalogApiResources.cs | 17 ---------- .../Api/Catalog/CatalogController.cs | 7 +--- .../Infrastructure/Catalog/CatalogClient.cs | 5 --- .../Infrastructure/Catalog/CatalogConfig.cs | 12 ------- .../Infrastructure/Catalog/CatalogDtos.cs | 22 ++----------- .../Catalog/CatalogTokenProvider.cs | 11 ------- src/SelfService/Logging.cs | 3 -- 15 files changed, 11 insertions(+), 124 deletions(-) diff --git a/mise.toml b/mise.toml index 4d9f12f3..f11b2346 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,5 @@ [env] -_.file = '.env' \ No newline at end of file +_.file = '.env' + +[tools] +dotnet = "8" diff --git a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs index 38ad6ea4..40abee70 100644 --- a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs +++ b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs @@ -28,7 +28,6 @@ ICapabilityRepository capabilityRepository private static CatalogConfig SingleCluster(string cluster = "local") => new(new[] { new CatalogClusterEndpoint(cluster, new Uri("http://ssu-catalog:8080")) }, scope: ""); - // ---- endpoint-registry parser ---- [Fact] public void ParseEndpoints_parses_cluster_url_csv() @@ -54,7 +53,6 @@ public void ParseEndpoints_skips_blank_and_malformed(string? raw) Assert.Empty(CatalogConfig.ParseEndpoints(raw)); } - // ---- merge + capability filter + name join ---- [Fact] public async Task Merge_keeps_only_capability_owned_apps_and_joins_name() @@ -248,7 +246,6 @@ public async Task HasDocs_filter_selects_apps_with_api_docs() Assert.Equal("undocumented", Assert.Single(withoutDocs.Items).Name); } - // ---- unavailability contract ---- [Fact] public async Task All_clusters_fail_reports_unavailable_with_no_items() @@ -273,7 +270,6 @@ public async Task All_clusters_fail_reports_unavailable_with_no_items() Assert.Equal(1, result.Availability.ClustersFailed); } - // ---- token provider ---- [Fact] public async Task TokenProvider_unconfigured_scope_returns_null_without_acquiring() diff --git a/src/SelfService/Application/CatalogApplicationService.cs b/src/SelfService/Application/CatalogApplicationService.cs index f18f2997..5fa63e7e 100644 --- a/src/SelfService/Application/CatalogApplicationService.cs +++ b/src/SelfService/Application/CatalogApplicationService.cs @@ -4,7 +4,6 @@ namespace SelfService.Application; -/// Availability summary surfaced in every catalog endpoint's meta envelope. public sealed record CatalogAvailability( bool CatalogAvailable, int ClustersQueried, @@ -13,7 +12,6 @@ public sealed record CatalogAvailability( DateTime? PublishedAt ); -/// A query result: the matching items plus the cross-cluster availability summary. public sealed record CatalogResult(IReadOnlyList Items, CatalogAvailability Availability); public sealed record ApplicationFilters( @@ -43,22 +41,13 @@ Task> GetDependencies( ); } -/// -/// Caching proxy over the per-cluster ssu-catalog services. On cache miss it fans out one -/// full-snapshot fetch per cluster, concatenates, then once joins on capabilityId against the -/// authoritative Capability data (filtering to capability-owned apps and attaching the name). All -/// query methods read from the single cached merged structure. No persistence. -/// +/// Caching proxy over the per-cluster ssu-catalog services. On cache miss it does a +/// full-snapshot fetch per cluster, concatenates, then once joins on capabilityId against Capability data (filtering to capability-owned apps and attaching the name). All +/// query methods read from the single cached merged structure. Only stored in-memory public class CatalogApplicationService : ICatalogApplicationService { private const string CacheKey = "catalog:merged"; private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(45); - - // The cross-cluster fan-out populates a shared, cached snapshot, so its lifetime must not be - // tied to any single inbound request. Binding it to a request's RequestAborted token meant a - // browser reload/navigation mid-fetch cancelled the upstream read (TaskCanceledException → - // SocketException ECANCELED), which was then misreported as a failed cluster. Give the fetch - // its own bounded budget instead so a genuinely hung upstream is still capped. private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20); private readonly CatalogConfig _config; @@ -160,9 +149,6 @@ public async Task> GetDependencies( private static bool HasDocs(ApplicationEntryDto app) => app.Services.Any(s => s.ApiDocs.Count > 0); - // Deliberately takes no CancellationToken: the populated snapshot is cached and shared across - // all callers, so the fan-out must not inherit any one request's RequestAborted. Each fetch - // gets an independent timeout budget instead (see FetchTimeout). private Task GetMerged() { return _cache.GetOrCreateAsync( @@ -180,7 +166,6 @@ private async Task FetchAndMerge(CancellationToken cancellationTo { var registry = _config.Clusters; - // Single full-snapshot fetch per cluster (decision 9); per-cluster failure → null. var fetches = registry .Select(async endpoint => { @@ -206,7 +191,6 @@ private async Task FetchAndMerge(CancellationToken cancellationTo continue; } - // Stamp the registry cluster name (authoritative) onto every entry. foreach (var app in snapshot.Applications) { app.Cluster = endpoint.Cluster; @@ -219,8 +203,6 @@ private async Task FetchAndMerge(CancellationToken cancellationTo apps.AddRange(snapshot.Applications); namespaces.AddRange(snapshot.Namespaces); dependencies.AddRange(snapshot.Dependencies); - // Skip zero-value timestamps: an older ssu-catalog that doesn't emit the field - // deserializes to default(DateTime) (year 1), which must not poison the min(). if (snapshot.CollectedAt.Year > 1) { collectedTimes.Add(snapshot.CollectedAt); @@ -235,13 +217,10 @@ private async Task FetchAndMerge(CancellationToken cancellationTo CatalogAvailable: registry.Count > 0 && clustersFailed < registry.Count, ClustersQueried: registry.Count, ClustersFailed: clustersFailed, - // Stalest snapshot bounds the freshness of the merged view; null if all clusters failed. CollectedAt: collectedTimes.Count > 0 ? collectedTimes.Min() : null, PublishedAt: publishedTimes.Count > 0 ? publishedTimes.Min() : null ); - // Join once: resolve each distinct capabilityId to a real Capability, keeping only owned - // apps/namespaces and attaching the authoritative name. var capabilityNames = await ResolveCapabilityNames(apps, namespaces); var ownedApps = apps.Where(a => @@ -252,12 +231,6 @@ private async Task FetchAndMerge(CancellationToken cancellationTo .Where(n => TryAttachCapability(n.CapabilityId, capabilityNames, name => n.CapabilityName = name)) .ToList(); - // Keep dependency edges touching a capability-owned namespace on EITHER end. - // Source-owned keeps a workload's OUTBOUND edges (the in-cluster app calling an - // external/other target); target-owned keeps its INBOUND edges (another workload - // — e.g. ssu-catalog probing it, whose source is external/unowned — connecting to - // the owned app). A source-only filter drops every inbound edge, so the portal's - // connections graph shows outbound-only. Best-effort overlay. var ownedNamespaceKeys = ownedApps .Select(a => (a.Cluster, a.Namespace)) .Concat(ownedNamespaces.Select(n => (n.Cluster, n.Name))) diff --git a/src/SelfService/Configuration/Domain.cs b/src/SelfService/Configuration/Domain.cs index 27fb1123..c26e57d2 100644 --- a/src/SelfService/Configuration/Domain.cs +++ b/src/SelfService/Configuration/Domain.cs @@ -191,14 +191,11 @@ public static void AddDomain(this WebApplicationBuilder builder) client.BaseAddress = confluentGatewayApiEndpoint; }); - // ssu-catalog integration — caching proxy, no persistence. The endpoint registry is - // per-cluster (SS_CATALOG_ENDPOINTS); the downstream scope is shared (SS_CATALOG_SCOPE). var catalogEndpoints = CatalogConfig.ParseEndpoints(builder.Configuration["SS_CATALOG_ENDPOINTS"]); var catalogScope = builder.Configuration["SS_CATALOG_SCOPE"]; builder.Services.AddSingleton(new CatalogConfig(catalogEndpoints, catalogScope)); builder.Services.AddMemoryCache(); builder.Services.AddScoped(); - // No BaseAddress: CatalogClient calls each per-cluster URL from the registry explicitly. builder.Services.AddHttpClient(); builder.Services.AddTransient(); } diff --git a/src/SelfService/Configuration/Observability.cs b/src/SelfService/Configuration/Observability.cs index 284799ce..5e69b1ba 100644 --- a/src/SelfService/Configuration/Observability.cs +++ b/src/SelfService/Configuration/Observability.cs @@ -41,7 +41,7 @@ public static void AddObservability(this WebApplicationBuilder builder) conf.AddOtlpExporter(); conf.AddPrometheusHttpListener(conf => { - conf.UriPrefixes = new string[] { "http://localhost:8888/" }; + conf.UriPrefixes = new string[] { "http://*:8888/" }; }); }); } diff --git a/src/SelfService/Configuration/Security.cs b/src/SelfService/Configuration/Security.cs index 6194b436..5b6249f2 100644 --- a/src/SelfService/Configuration/Security.cs +++ b/src/SelfService/Configuration/Security.cs @@ -13,17 +13,11 @@ public static void AddSecurity(this WebApplicationBuilder builder) builder .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) - // Enables ITokenAcquisition so selfservice-api can acquire a client-credentials - // (app-only) token as its own service principal to call ssu-catalog downstream. - // In local dev SS_CATALOG_SCOPE is empty, so no token is acquired (see CatalogTokenProvider). .EnableTokenAcquisitionToCallDownstreamApi() .AddInMemoryTokenCaches(); builder.Services.AddAuthorization(); - // NOTE: enable to debug authentication issues - // IdentityModelEventSource.ShowPII = true; - AutoRegisterAuthorizationHandlers(builder); } diff --git a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs index 22f915e3..09635cf1 100644 --- a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs @@ -627,7 +627,6 @@ private ResourceLink CreateMembersLinkFor(Capability capability) private ResourceLink CreateDeploymentsLinkFor(Capability capability) { - // Open read (like members): the link is always present so the portal section always renders. return new ResourceLink( href: _linkGenerator.GetUriByAction( httpContext: HttpContext, diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs index 4d94cbeb..131722f1 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -6,10 +6,6 @@ namespace SelfService.Infrastructure.Api.Catalog; -/// -/// Maps merged catalog results ( over the wire DTOs) into the -/// SSU-facing API resources, attaching HATEOAS links via . -/// public class CatalogApiResourceFactory { private readonly IHttpContextAccessor _httpContextAccessor; diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs index ac7f91c3..faf26c56 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -3,29 +3,13 @@ namespace SelfService.Infrastructure.Api.Catalog; -// Response DTOs for the catalog HTTP surface. These are the SSU-facing shapes — the raw -// ssu-catalog wire DTOs (Infrastructure/Catalog/CatalogDtos.cs) are never returned directly. -// Property names serialize to camelCase (ASP.NET Core web defaults); `_links` follows the -// HATEOAS convention used across the API. - -/// Availability summary surfaced in every catalog endpoint's meta envelope. public class CatalogMetaApiResource { public bool CatalogAvailable { get; init; } public int ClustersQueried { get; init; } public int ClustersFailed { get; init; } - /// - /// When ssu-catalog began its scan (min across queried clusters). Null when no cluster returned - /// a snapshot. Informational — for the "last updated" age prefer . - /// public DateTime? CollectedAt { get; init; } - - /// - /// When ssu-catalog finished assembling the snapshot — i.e. when the data became current (min - /// across queried clusters). The portal renders this as the "last updated" age. Null when no - /// cluster returned a snapshot. - /// public DateTime? PublishedAt { get; init; } } @@ -202,7 +186,6 @@ public class DependencyApiResource public string Details { get; init; } = ""; } -// ---- List envelopes: { data, meta, _links } ---- public class CatalogDeploymentsApiResource { diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs index 05effb34..e868ef5e 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs @@ -3,12 +3,7 @@ namespace SelfService.Infrastructure.Api.Catalog; -/// -/// Cross-cluster, capability-scoped view of the ssu-catalog application catalog. All endpoints -/// are open reads (authenticated, no special permission) and always return 200 — when the -/// upstream catalog is unreachable the payload carries empty data plus -/// meta.catalogAvailable = false (the unavailability contract). -/// +// TODO, add service catalogue specific RBAC scopes [Route("catalog")] [Produces("application/json")] [ApiController] diff --git a/src/SelfService/Infrastructure/Catalog/CatalogClient.cs b/src/SelfService/Infrastructure/Catalog/CatalogClient.cs index 3f186c47..edb03e2d 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogClient.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogClient.cs @@ -5,11 +5,6 @@ namespace SelfService.Infrastructure.Catalog; public interface ICatalogClient { - /// - /// Fetches the full catalog snapshot from one cluster's ssu-catalog. Returns null on any - /// failure (network, non-success status, deserialization) — a failed cluster is skipped, - /// never fatal. - /// Task GetCatalog(Uri clusterUrl, CancellationToken cancellationToken = default); } diff --git a/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs b/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs index 81cb935d..bea54656 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs @@ -1,20 +1,12 @@ namespace SelfService.Infrastructure.Catalog; -/// A single per-cluster ssu-catalog endpoint from the registry. public sealed record CatalogClusterEndpoint(string Cluster, Uri Url); -/// -/// Catalog integration configuration. The per-cluster endpoint registry comes from -/// SS_CATALOG_ENDPOINTS ("cluster=url" CSV); the single shared downstream-API scope -/// comes from SS_CATALOG_SCOPE. An empty scope (local dev) disables token acquisition, -/// so no Authorization header is sent and OIDC-disabled ssu-catalog accepts the call. -/// public sealed class CatalogConfig { public IReadOnlyList Clusters { get; } public string Scope { get; } - /// True when a non-empty scope is configured, i.e. a bearer token must be acquired. public bool TokenAcquisitionEnabled => !string.IsNullOrWhiteSpace(Scope); public CatalogConfig(IReadOnlyList clusters, string? scope) @@ -23,10 +15,6 @@ public CatalogConfig(IReadOnlyList clusters, string? sco Scope = scope ?? ""; } - /// - /// Parses a "cluster=url,cluster2=url2" CSV into the endpoint registry. Blank and - /// malformed entries (missing '=', empty cluster/url, non-absolute URL) are skipped. - /// public static IReadOnlyList ParseEndpoints(string? raw) { var result = new List(); diff --git a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs index 99a68752..0a2361d9 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -2,16 +2,6 @@ namespace SelfService.Infrastructure.Catalog; -// DTOs mirroring ssu-catalog/internal/model/catalog.go. Deserialized with -// PropertyNameCaseInsensitive = true, so PascalCase properties map to the -// service's camelCase JSON without per-property attributes. -// -// Collection properties use null-coalescing setters. ssu-catalog is Go, where a -// nil slice marshals to JSON `null` (not `[]`) unless tagged omitempty. A property -// initializer (`= new()`) only applies when the JSON key is ABSENT; an explicit -// `null` value overwrites it. The coalescing setter guarantees these are never null -// after deserialization, so the mapper and merge/join code can enumerate freely. - /// Envelope returned by every ssu-catalog endpoint: { data, meta }. public sealed class CatalogEnvelope { @@ -101,14 +91,14 @@ public List Containers get => _containers; set => _containers = value ?? new(); } - + public List RepoUrls { get => _repoUrls; set => _repoUrls = value ?? new(); } public DeploymentSourceDto? DeploymentSource { get; set; } - + public AppMetadataDto? Metadata { get; set; } // Best-effort owner (may be empty) @@ -137,16 +127,12 @@ public List Databases public Dictionary? Labels { get; set; } public Dictionary? Annotations { get; set; } - /// Beyla's detected runtime/language (e.g. "go", "dotnet"); empty/absent when undetected. public string? Runtime { get; set; } - /// Inbound HTTP throughput (req/s), Beyla-observed; null/absent when no inbound HTTP seen. public double? RequestRate { get; set; } - /// 5xx share of inbound traffic (0..1); meaningful only with RequestRate > 0. public double? ErrorRate { get; set; } - /// Authoritative capability name, joined SSU-side (not part of the wire model). [JsonIgnore] public string? CapabilityName { get; set; } } @@ -182,8 +168,6 @@ public List ApiDocs get => _apiDocs; set => _apiDocs = value ?? new(); } - // Serve-time reachability overlay from ssu-catalog; omitempty on the Go side, - // so it arrives absent (→ empty) for hosts with no verdict. public List Reachability { get => _reachability; @@ -226,7 +210,6 @@ public sealed class DeploymentSourceDto public string AppName { get; set; } = ""; } -/// Author-declared workload metadata (dfds.cloud/description + dfds.cloud/link.*). public sealed class AppMetadataDto { private List _links = new(); @@ -292,7 +275,6 @@ public sealed class ApiDocInfoDto public string ExternalUrl { get; set; } = ""; } -/// Active ingress-reachability verdict per exposed host (ssu-catalog serve-time overlay). public sealed class ReachabilityResultDto { public string Host { get; set; } = ""; diff --git a/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs b/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs index c8aa3841..9860055f 100644 --- a/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs +++ b/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs @@ -4,18 +4,9 @@ namespace SelfService.Infrastructure.Catalog; public interface ICatalogTokenProvider { - /// - /// Acquires an app-only access token for the configured catalog scope, or null when no - /// scope is configured (local dev) — in which case the caller omits the Authorization header. - /// Task GetAccessToken(CancellationToken cancellationToken = default); } -/// -/// Thin wrapper over ITokenAcquisition.GetAccessTokenForAppAsync. selfservice-api acquires the -/// token as its own service principal; one shared app token is used across all clusters (only the -/// endpoint registry is per-cluster). -/// public class CatalogTokenProvider : ICatalogTokenProvider { private readonly CatalogConfig _config; @@ -47,8 +38,6 @@ ILogger logger } catch (Exception ex) { - // Fail soft: a missing token means a 401 downstream, which the client treats as a - // per-cluster failure (meta.catalogAvailable reflects it). Never throw here. _logger.LogError(ex, "Failed to acquire catalog access token for scope {Scope}", _config.Scope); return null; } diff --git a/src/SelfService/Logging.cs b/src/SelfService/Logging.cs index 08dc4413..bc45cd0f 100644 --- a/src/SelfService/Logging.cs +++ b/src/SelfService/Logging.cs @@ -21,9 +21,6 @@ public static void AddLogging(this WebApplicationBuilder builder) .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.IdentityModel", LogEventLevel.Warning) - // MSAL routes its (Info-level) logging through Microsoft.Identity.Web's - // ITokenAcquisition logger — the "MSAL 4.x … .NET … Darwin …" banner spam - // on every token acquisition (e.g. the catalog token provider). Keep warnings. .MinimumLevel.Override("Microsoft.Identity.Web", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) From 65c118b29003766bc425dfcbdf31d582e123ddb0 Mon Sep 17 00:00:00 2001 From: "Emil H. Clausen" Date: Mon, 20 Jul 2026 19:24:00 +0200 Subject: [PATCH 7/7] formatting --- .../Application/TestCatalogApplicationService.cs | 4 ---- .../Infrastructure/Catalog/TestCatalogClient.cs | 5 +---- .../Infrastructure/Api/Catalog/CatalogApiResources.cs | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs index 40abee70..9038403f 100644 --- a/src/SelfService.Tests/Application/TestCatalogApplicationService.cs +++ b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs @@ -28,7 +28,6 @@ ICapabilityRepository capabilityRepository private static CatalogConfig SingleCluster(string cluster = "local") => new(new[] { new CatalogClusterEndpoint(cluster, new Uri("http://ssu-catalog:8080")) }, scope: ""); - [Fact] public void ParseEndpoints_parses_cluster_url_csv() { @@ -53,7 +52,6 @@ public void ParseEndpoints_skips_blank_and_malformed(string? raw) Assert.Empty(CatalogConfig.ParseEndpoints(raw)); } - [Fact] public async Task Merge_keeps_only_capability_owned_apps_and_joins_name() { @@ -246,7 +244,6 @@ public async Task HasDocs_filter_selects_apps_with_api_docs() Assert.Equal("undocumented", Assert.Single(withoutDocs.Items).Name); } - [Fact] public async Task All_clusters_fail_reports_unavailable_with_no_items() { @@ -270,7 +267,6 @@ public async Task All_clusters_fail_reports_unavailable_with_no_items() Assert.Equal(1, result.Availability.ClustersFailed); } - [Fact] public async Task TokenProvider_unconfigured_scope_returns_null_without_acquiring() { diff --git a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs index 7f787426..8308f4fb 100644 --- a/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs +++ b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs @@ -155,10 +155,7 @@ public async Task GetCatalog_deserializes_all_repo_urls_and_null_coalesces() var snapshot = await client.GetCatalog(new Uri("http://ssu-catalog:8080")); var both = snapshot!.Applications.Single(a => a.Name == "gitops-plus-declared"); - Assert.Equal( - new[] { "https://github.com/dfds/ssu-apps", "https://github.com/dfds/api" }, - both.RepoUrls - ); + Assert.Equal(new[] { "https://github.com/dfds/ssu-apps", "https://github.com/dfds/api" }, both.RepoUrls); Assert.Equal("https://github.com/dfds/ssu-apps", both.DeploymentSource!.RepoUrl); var none = snapshot.Applications.Single(a => a.Name == "no-repos"); diff --git a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs index faf26c56..8a7ec7e3 100644 --- a/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -186,7 +186,6 @@ public class DependencyApiResource public string Details { get; init; } = ""; } - public class CatalogDeploymentsApiResource { public IReadOnlyList Data { get; init; } = Array.Empty();