Sprint 2+3+5: Registry search, workflow chain, unified security data
Sprint 2 — Registry image search (S2-T01/T02/T03):
Harbor plugin: SearchRepositoriesAsync + ListArtifactsAsync calling
Harbor /api/v2.0/search and /api/v2.0/projects/*/repositories/*/artifacts
Platform endpoint: GET /api/v1/registries/images/search proxies to
Harbor fixture, returns aggregated RegistryImage[] response
Frontend: release-management.client.ts now calls /api/v1/registries/*
instead of the nonexistent /api/registry/* path
Gateway route: /api/v1/registries → platform (ReverseProxy)
Sprint 3 — Workflow chain links (S3-T01/T02/T03/T05):
S3-T01: Integration detail health tab shows "Scan your first image"
CTA after successful registry connection test
S3-T02: Scan submit page already had "View findings" link (verified)
S3-T03: Triage findings detail shows "Check policy gates" banner
after recording a VEX decision
S3-T05: Promotions list + detail show "Review blocking finding"
link when promotion is blocked by gate failure
Sprint 5 — Unified security data (S5-T01):
Security Posture now queries VULNERABILITY_API for triage stats
Risk Posture card shows real finding count from triage (was hardcoded 0)
Risk label computed from triage severity breakdown (GUARDED→HIGH)
Blocking Items shows critical+high counts from triage
"View in Vulnerabilities workspace" drilldown link added
Angular build: 0 errors. .NET builds: 0 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation endpoint for registry image search.
|
||||
/// Proxies search to connected registry integrations (Harbor fixture by default)
|
||||
/// and returns a unified response format for the frontend.
|
||||
/// </summary>
|
||||
public static class RegistrySearchEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapRegistrySearchEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/registries")
|
||||
.WithTags("Registry Search")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsRead)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/images/search", SearchImagesAsync)
|
||||
.WithName("Registries.SearchImages")
|
||||
.WithSummary("Search container images across connected registries");
|
||||
|
||||
group.MapGet("/images/digests", GetImageDigestsAsync)
|
||||
.WithName("Registries.GetImageDigests")
|
||||
.WithSummary("Get artifact digests for a specific repository");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchImagesAsync(
|
||||
HttpContext ctx,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
[FromQuery] string? q,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Trim().Length < 2)
|
||||
{
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = null
|
||||
});
|
||||
}
|
||||
|
||||
var query = q.Trim();
|
||||
var logger = loggerFactory.CreateLogger("RegistrySearch");
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("HarborFixture");
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v2.0/search?q={Uri.EscapeDataString(query)}",
|
||||
ctx.RequestAborted);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Harbor search returned {StatusCode} for query '{Query}'",
|
||||
response.StatusCode, query);
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted);
|
||||
var searchResult = JsonSerializer.Deserialize<HarborSearchResult>(content, JsonOptions);
|
||||
|
||||
var items = (searchResult?.Repository ?? [])
|
||||
.Select(r =>
|
||||
{
|
||||
var fullName = r.RepositoryName ?? string.Empty;
|
||||
var shortName = fullName.Contains('/')
|
||||
? fullName.Substring(fullName.LastIndexOf('/') + 1)
|
||||
: fullName;
|
||||
|
||||
return new RegistryImageItem
|
||||
{
|
||||
Name = shortName,
|
||||
Repository = fullName,
|
||||
Tags = [],
|
||||
Digests = [],
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = items.Count,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Harbor registry unreachable during image search for query '{Query}'", query);
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetImageDigestsAsync(
|
||||
HttpContext ctx,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
[FromQuery] string? repository,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(repository))
|
||||
{
|
||||
return Results.BadRequest(new { error = "repository_required" });
|
||||
}
|
||||
|
||||
var repo = repository.Trim();
|
||||
var logger = loggerFactory.CreateLogger("RegistrySearch");
|
||||
// Parse project/repository from the full repository name
|
||||
var slashIndex = repo.IndexOf('/');
|
||||
if (slashIndex < 0)
|
||||
{
|
||||
// No project prefix - return empty
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repo,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
|
||||
var project = repo.Substring(0, slashIndex);
|
||||
var repoName = repo.Substring(slashIndex + 1);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("HarborFixture");
|
||||
var encodedRepo = Uri.EscapeDataString(repoName);
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories/{encodedRepo}/artifacts",
|
||||
ctx.RequestAborted);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted);
|
||||
var artifacts = JsonSerializer.Deserialize<List<HarborArtifactDto>>(content, JsonOptions) ?? [];
|
||||
|
||||
var tags = artifacts
|
||||
.SelectMany(a => (a.Tags ?? []).Select(t => t.Name ?? string.Empty))
|
||||
.Where(t => t.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var digests = artifacts
|
||||
.SelectMany(a =>
|
||||
{
|
||||
var artifactTags = (a.Tags ?? []).Select(t => t.Name ?? "untagged").ToList();
|
||||
if (artifactTags.Count == 0) artifactTags.Add("untagged");
|
||||
|
||||
return artifactTags.Select(tag => new DigestEntry
|
||||
{
|
||||
Tag = tag,
|
||||
Digest = a.Digest ?? string.Empty,
|
||||
PushedAt = a.PushTime?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty
|
||||
});
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = tags,
|
||||
Digests = digests
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Harbor registry unreachable during digest lookup for '{Repository}'", repo);
|
||||
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response DTOs ──────────────────────────────────────────────
|
||||
|
||||
private sealed class RegistrySearchResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<RegistryImageItem> Items { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
[JsonPropertyName("registryId")]
|
||||
public string? RegistryId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RegistryImageItem
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string Repository { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("digests")]
|
||||
public List<DigestEntry> Digests { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class DigestEntry
|
||||
{
|
||||
[JsonPropertyName("tag")]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pushedAt")]
|
||||
public string PushedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Harbor API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class HarborSearchResult
|
||||
{
|
||||
public List<HarborSearchRepository>? Repository { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborSearchRepository
|
||||
{
|
||||
public string? RepositoryName { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborArtifactDto
|
||||
{
|
||||
public string? Digest { get; set; }
|
||||
public List<HarborTagDto>? Tags { get; set; }
|
||||
public DateTimeOffset? PushTime { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborTagDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,17 @@ builder.Services.AddHttpClient("AuthorityInternal", client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("HarborFixture", client =>
|
||||
{
|
||||
var harborUrl = builder.Configuration["STELLAOPS_HARBOR_URL"]
|
||||
?? builder.Configuration["Platform:HarborFixtureUrl"]
|
||||
?? "http://harbor-fixture.stella-ops.local";
|
||||
client.BaseAddress = new Uri(harborUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
@@ -345,6 +356,7 @@ app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
app.MapSeedEndpoints();
|
||||
app.MapMigrationAdminEndpoints();
|
||||
app.MapRegistrySearchEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
Reference in New Issue
Block a user