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 new file mode 100644 index 00000000..9038403f --- /dev/null +++ b/src/SelfService.Tests/Application/TestCatalogApplicationService.cs @@ -0,0 +1,287 @@ +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: ""); + + [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)); + } + + [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 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() + { + 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); + } + + [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); + } + + [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..8308f4fb --- /dev/null +++ b/src/SelfService.Tests/Infrastructure/Catalog/TestCatalogClient.cs @@ -0,0 +1,289 @@ +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_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_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() + { + 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..5fa63e7e --- /dev/null +++ b/src/SelfService/Application/CatalogApplicationService.cs @@ -0,0 +1,312 @@ +using Microsoft.Extensions.Caching.Memory; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Catalog; + +namespace SelfService.Application; + +public sealed record CatalogAvailability( + bool CatalogAvailable, + int ClustersQueried, + int ClustersFailed, + DateTime? CollectedAt, + DateTime? PublishedAt +); + +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 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); + 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); + + 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; + + 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; + var collectedTimes = new List(); + var publishedTimes = new List(); + + foreach (var (endpoint, snapshot) in results) + { + if (snapshot is null) + { + clustersFailed++; + continue; + } + + 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); + 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, + CollectedAt: collectedTimes.Count > 0 ? collectedTimes.Min() : null, + PublishedAt: publishedTimes.Count > 0 ? publishedTimes.Min() : null + ); + + 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(); + + 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)) + || ownedNamespaceKeys.Contains((d.Target.Cluster, d.Target.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..c26e57d2 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,13 @@ public static void AddDomain(this WebApplicationBuilder builder) { client.BaseAddress = confluentGatewayApiEndpoint; }); + + 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(); + builder.Services.AddHttpClient(); + builder.Services.AddTransient(); } } diff --git a/src/SelfService/Configuration/Security.cs b/src/SelfService/Configuration/Security.cs index e19441b8..5b6249f2 100644 --- a/src/SelfService/Configuration/Security.cs +++ b/src/SelfService/Configuration/Security.cs @@ -12,13 +12,12 @@ public static void AddSecurity(this WebApplicationBuilder builder) { builder .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + .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 fcd7a188..09635cf1 100644 --- a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs @@ -625,6 +625,20 @@ private ResourceLink CreateMembersLinkFor(Capability capability) ); } + private ResourceLink CreateDeploymentsLinkFor(Capability capability) + { + 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 +803,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..131722f1 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResourceFactory.cs @@ -0,0 +1,268 @@ +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; + +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, + CollectedAt = availability.CollectedAt, + PublishedAt = availability.PublishedAt, + }; + + 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(), + RepoUrls = app.RepoUrls, + 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, + }, + 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(), + 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(), + Runtime = string.IsNullOrEmpty(app.Runtime) ? null : app.Runtime, + RequestRate = app.RequestRate, + ErrorRate = app.ErrorRate, + 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(), + 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) => + 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..8a7ec7e3 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogApiResources.cs @@ -0,0 +1,228 @@ +using System.Text.Json.Serialization; +using SelfService.Infrastructure.Api.Capabilities; + +namespace SelfService.Infrastructure.Api.Catalog; + +public class CatalogMetaApiResource +{ + public bool CatalogAvailable { get; init; } + public int ClustersQueried { get; init; } + public int ClustersFailed { get; init; } + + public DateTime? CollectedAt { get; init; } + public DateTime? PublishedAt { 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 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; } = ""; + 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 IReadOnlyList Reachability { 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 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; } = ""; + 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 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; } = ""; + + public IReadOnlyList Services { get; init; } = Array.Empty(); + 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(); + + 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; } = ""; +} + +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..e868ef5e --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Catalog/CatalogController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Mvc; +using SelfService.Application; + +namespace SelfService.Infrastructure.Api.Catalog; + +// TODO, add service catalogue specific RBAC scopes +[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..edb03e2d --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogClient.cs @@ -0,0 +1,66 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace SelfService.Infrastructure.Catalog; + +public interface ICatalogClient +{ + 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..bea54656 --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogConfig.cs @@ -0,0 +1,51 @@ +namespace SelfService.Infrastructure.Catalog; + +public sealed record CatalogClusterEndpoint(string Cluster, Uri Url); + +public sealed class CatalogConfig +{ + public IReadOnlyList Clusters { get; } + public string Scope { get; } + + public bool TokenAcquisitionEnabled => !string.IsNullOrWhiteSpace(Scope); + + public CatalogConfig(IReadOnlyList clusters, string? scope) + { + Clusters = clusters; + Scope = scope ?? ""; + } + + 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..0a2361d9 --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogDtos.cs @@ -0,0 +1,318 @@ +using System.Text.Json.Serialization; + +namespace SelfService.Infrastructure.Catalog; + +/// 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 DateTime PublishedAt { 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(); + private List _repoUrls = 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(); + } + + 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; } = ""; + 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; } + + public string? Runtime { get; set; } + + public double? RequestRate { get; set; } + + public double? ErrorRate { get; set; } + + [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(); + private List _reachability = 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 List Reachability + { + get => _reachability; + set => _reachability = 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 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(); + + 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 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; } = ""; + 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..9860055f --- /dev/null +++ b/src/SelfService/Infrastructure/Catalog/CatalogTokenProvider.cs @@ -0,0 +1,45 @@ +using Microsoft.Identity.Web; + +namespace SelfService.Infrastructure.Catalog; + +public interface ICatalogTokenProvider +{ + Task GetAccessToken(CancellationToken cancellationToken = default); +} + +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) + { + _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..bc45cd0f 100644 --- a/src/SelfService/Logging.cs +++ b/src/SelfService/Logging.cs @@ -21,6 +21,7 @@ public static void AddLogging(this WebApplicationBuilder builder) .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.IdentityModel", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Identity.Web", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) .MinimumLevel.Override(