Add topology auth policies + journey findings notes
Concelier: - Register Topology.Read, Topology.Manage, Topology.Admin authorization policies mapped to OrchRead/OrchOperate/PlatformContextRead/IntegrationWrite scopes. Previously these policies were referenced by endpoints but never registered, causing System.InvalidOperationException on every topology API call. Gateway routes: - Simplified targets/environments routes (removed specific sub-path routes, use catch-all patterns instead) - Changed environments base route to JobEngine (where CRUD lives) - Changed to ReverseProxy type for all topology routes KNOWN ISSUE (not yet fixed): - ReverseProxy routes don't forward the gateway's identity envelope to Concelier. The regions/targets/bindings endpoints return 401 because hasPrincipal=False — the gateway authenticates the user but doesn't pass the identity to the backend via ReverseProxy. Microservice routes use Valkey transport which includes envelope headers. Topology endpoints need either: (a) Valkey transport registration in Concelier, or (b) Concelier configured to accept raw bearer tokens on ReverseProxy paths. This is an architecture-level fix. Journey findings collected so far: - Integration wizard (Harbor + GitHub App): works end-to-end - Advisory Check All: fixed (parallel individual checks) - Mirror domain creation: works, generate-immediately fails silently - Topology wizard Step 1 (Region): blocked by auth passthrough issue - Topology wizard Step 2 (Environment): POST to JobEngine needs verify - User ID resolution: raw hashes shown everywhere Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,104 +17,119 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
{
|
||||
private const string MirrorManagePolicy = "Concelier.Sources.Manage";
|
||||
private const string MirrorReadPolicy = "Concelier.Advisories.Read";
|
||||
private const string MirrorBasePath = "/api/v1/advisory-sources/mirror";
|
||||
private const string MirrorIndexPath = "/concelier/exports/index.json";
|
||||
private const string MirrorDomainRoot = "/concelier/exports/mirror";
|
||||
|
||||
public static void MapMirrorDomainManagementEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/mirror")
|
||||
var group = app.MapGroup(MirrorBasePath)
|
||||
.WithTags("Mirror Domain Management")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /config — read current mirror configuration
|
||||
group.MapGet("/config", ([FromServices] IOptions<MirrorConfigOptions> options) =>
|
||||
group.MapGet("/config", ([FromServices] IMirrorConfigStore configStore, [FromServices] IMirrorConsumerConfigStore consumerStore) =>
|
||||
{
|
||||
var config = options.Value;
|
||||
return HttpResults.Ok(new MirrorConfigResponse
|
||||
{
|
||||
Mode = config.Mode,
|
||||
OutputRoot = config.OutputRoot,
|
||||
ConsumerBaseAddress = config.ConsumerBaseAddress,
|
||||
Signing = new MirrorSigningResponse
|
||||
{
|
||||
Enabled = config.SigningEnabled,
|
||||
Algorithm = config.SigningAlgorithm,
|
||||
KeyId = config.SigningKeyId,
|
||||
},
|
||||
AutoRefreshEnabled = config.AutoRefreshEnabled,
|
||||
RefreshIntervalMinutes = config.RefreshIntervalMinutes,
|
||||
});
|
||||
var config = configStore.GetConfig();
|
||||
var consumer = consumerStore.GetConsumerConfig();
|
||||
return HttpResults.Ok(MapMirrorConfig(config, consumer));
|
||||
})
|
||||
.WithName("GetMirrorConfig")
|
||||
.WithSummary("Read current mirror configuration")
|
||||
.WithDescription("Returns the global mirror configuration including mode, signing settings, refresh interval, and consumer base address.")
|
||||
.WithDescription("Returns the mirror operating mode together with the current consumer connection state that the operator-facing UI renders.")
|
||||
.Produces<MirrorConfigResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
// PUT /config — update mirror configuration
|
||||
group.MapPut("/config", ([FromBody] UpdateMirrorConfigRequest request, [FromServices] IMirrorConfigStore store, CancellationToken ct) =>
|
||||
group.MapPut("/config", async ([FromBody] UpdateMirrorConfigRequest request, [FromServices] IMirrorConfigStore configStore, [FromServices] IMirrorConsumerConfigStore consumerStore, CancellationToken ct) =>
|
||||
{
|
||||
// Note: actual persistence will be implemented with the config store
|
||||
return HttpResults.Ok(new { updated = true });
|
||||
await configStore.UpdateConfigAsync(request, ct).ConfigureAwait(false);
|
||||
var config = configStore.GetConfig();
|
||||
var consumer = consumerStore.GetConsumerConfig();
|
||||
return HttpResults.Ok(MapMirrorConfig(config, consumer));
|
||||
})
|
||||
.WithName("UpdateMirrorConfig")
|
||||
.WithSummary("Update mirror mode, signing, and refresh settings")
|
||||
.WithDescription("Updates the global mirror configuration. Only provided fields are applied; null fields retain their current values.")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.WithDescription("Updates the global mirror configuration and returns the updated operator-facing state.")
|
||||
.Produces<MirrorConfigResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
// GET /domains — list all configured mirror domains
|
||||
group.MapGet("/domains", ([FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
||||
group.MapGet("/health", ([FromServices] IMirrorDomainStore domainStore) =>
|
||||
{
|
||||
var domains = domainStore.GetAllDomains();
|
||||
return HttpResults.Ok(new MirrorHealthSummary
|
||||
{
|
||||
TotalDomains = domains.Count,
|
||||
FreshCount = domains.Count(domain => string.Equals(ComputeDomainStaleness(domain), "fresh", StringComparison.OrdinalIgnoreCase)),
|
||||
StaleCount = domains.Count(domain => string.Equals(ComputeDomainStaleness(domain), "stale", StringComparison.OrdinalIgnoreCase)),
|
||||
NeverGeneratedCount = domains.Count(domain => string.Equals(ComputeDomainStaleness(domain), "never_generated", StringComparison.OrdinalIgnoreCase)),
|
||||
TotalAdvisoryCount = domains.Sum(domain => domain.AdvisoryCount),
|
||||
});
|
||||
})
|
||||
.WithName("GetMirrorHealth")
|
||||
.WithSummary("Summarize mirror domain freshness")
|
||||
.WithDescription("Returns the operator dashboard health summary for configured mirror domains.")
|
||||
.Produces<MirrorHealthSummary>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
group.MapGet("/domains", ([FromServices] IMirrorDomainStore domainStore) =>
|
||||
{
|
||||
var domains = domainStore.GetAllDomains();
|
||||
return HttpResults.Ok(new MirrorDomainListResponse
|
||||
{
|
||||
Domains = domains.Select(MapDomainSummary).ToList(),
|
||||
Domains = domains.Select(MapDomain).ToList(),
|
||||
TotalCount = domains.Count,
|
||||
});
|
||||
})
|
||||
.WithName("ListMirrorDomains")
|
||||
.WithSummary("List all configured mirror domains")
|
||||
.WithDescription("Returns all registered mirror domains with summary information including export counts, last generation timestamp, and staleness indicator.")
|
||||
.WithDescription("Returns all registered mirror domains in the same shape consumed by the mirror dashboard cards.")
|
||||
.Produces<MirrorDomainListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
// POST /domains — create a new mirror domain
|
||||
group.MapPost("/domains", async ([FromBody] CreateMirrorDomainRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
var domainId = NormalizeDomainId(request.DomainId ?? request.Id);
|
||||
if (string.IsNullOrWhiteSpace(domainId) || string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "id_and_display_name_required" });
|
||||
}
|
||||
|
||||
var existing = domainStore.GetDomain(request.Id);
|
||||
var existing = domainStore.GetDomain(domainId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return HttpResults.Conflict(new { error = "domain_already_exists", domainId = request.Id });
|
||||
return HttpResults.Conflict(new { error = "domain_already_exists", domainId });
|
||||
}
|
||||
|
||||
var sourceIds = ResolveSourceIds(request.SourceIds, request.Exports);
|
||||
var exportFormat = request.ExportFormat?.Trim() ?? request.Exports?.FirstOrDefault()?.Format ?? "JSON";
|
||||
|
||||
var domain = new MirrorDomainRecord
|
||||
{
|
||||
Id = request.Id.Trim().ToLowerInvariant(),
|
||||
Id = domainId,
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
SourceIds = sourceIds,
|
||||
ExportFormat = exportFormat,
|
||||
RequireAuthentication = request.RequireAuthentication,
|
||||
MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? 120,
|
||||
MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? 600,
|
||||
Exports = (request.Exports ?? []).Select(e => new MirrorExportRecord
|
||||
{
|
||||
Key = e.Key,
|
||||
Format = e.Format ?? "json",
|
||||
Filters = e.Filters ?? new Dictionary<string, string>(),
|
||||
}).ToList(),
|
||||
MaxIndexRequestsPerHour = request.RateLimits?.IndexRequestsPerHour ?? request.MaxIndexRequestsPerHour ?? 120,
|
||||
MaxDownloadRequestsPerHour = request.RateLimits?.DownloadRequestsPerHour ?? request.MaxDownloadRequestsPerHour ?? 600,
|
||||
SigningEnabled = request.Signing?.Enabled ?? false,
|
||||
SigningAlgorithm = request.Signing?.Algorithm?.Trim() ?? "HMAC-SHA256",
|
||||
SigningKeyId = request.Signing?.KeyId?.Trim() ?? string.Empty,
|
||||
Exports = ResolveExports(sourceIds, exportFormat, request.Exports),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
await domainStore.SaveDomainAsync(domain, ct);
|
||||
await domainStore.SaveDomainAsync(domain, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Created($"/api/v1/mirror/domains/{domain.Id}", MapDomainDetail(domain));
|
||||
return HttpResults.Created($"{MirrorBasePath}/domains/{domain.Id}", MapDomain(domain));
|
||||
})
|
||||
.WithName("CreateMirrorDomain")
|
||||
.WithSummary("Create a new mirror domain with exports and filters")
|
||||
.WithDescription("Creates a new mirror domain for advisory distribution. The domain ID is normalized to lowercase. Exports define the data slices available for consumption.")
|
||||
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status201Created)
|
||||
.WithSummary("Create a new mirror domain")
|
||||
.WithDescription("Creates a new mirror domain using the operator-facing domain, signing, and rate-limit contract.")
|
||||
.Produces<MirrorDomainResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
@@ -128,12 +143,12 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
||||
}
|
||||
|
||||
return HttpResults.Ok(MapDomainDetail(domain));
|
||||
return HttpResults.Ok(MapDomain(domain));
|
||||
})
|
||||
.WithName("GetMirrorDomain")
|
||||
.WithSummary("Get mirror domain detail with all exports and status")
|
||||
.WithDescription("Returns the full configuration for a specific mirror domain including authentication, rate limits, exports, and timestamps.")
|
||||
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
|
||||
.WithSummary("Get mirror domain detail")
|
||||
.WithDescription("Returns the operator-facing mirror domain detail for a specific domain.")
|
||||
.Produces<MirrorDomainResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
@@ -147,34 +162,34 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
}
|
||||
|
||||
domain.DisplayName = request.DisplayName ?? domain.DisplayName;
|
||||
domain.SourceIds = ResolveSourceIds(request.SourceIds, request.Exports, domain.SourceIds);
|
||||
domain.ExportFormat = request.ExportFormat?.Trim() ?? domain.ExportFormat;
|
||||
domain.RequireAuthentication = request.RequireAuthentication ?? domain.RequireAuthentication;
|
||||
domain.MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? domain.MaxIndexRequestsPerHour;
|
||||
domain.MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? domain.MaxDownloadRequestsPerHour;
|
||||
domain.MaxIndexRequestsPerHour = request.RateLimits?.IndexRequestsPerHour ?? request.MaxIndexRequestsPerHour ?? domain.MaxIndexRequestsPerHour;
|
||||
domain.MaxDownloadRequestsPerHour = request.RateLimits?.DownloadRequestsPerHour ?? request.MaxDownloadRequestsPerHour ?? domain.MaxDownloadRequestsPerHour;
|
||||
domain.SigningEnabled = request.Signing?.Enabled ?? domain.SigningEnabled;
|
||||
domain.SigningAlgorithm = request.Signing?.Algorithm?.Trim() ?? domain.SigningAlgorithm;
|
||||
domain.SigningKeyId = request.Signing?.KeyId?.Trim() ?? domain.SigningKeyId;
|
||||
|
||||
if (request.Exports is not null)
|
||||
if (request.SourceIds is not null || request.Exports is not null || !string.IsNullOrWhiteSpace(request.ExportFormat))
|
||||
{
|
||||
domain.Exports = request.Exports.Select(e => new MirrorExportRecord
|
||||
{
|
||||
Key = e.Key,
|
||||
Format = e.Format ?? "json",
|
||||
Filters = e.Filters ?? new Dictionary<string, string>(),
|
||||
}).ToList();
|
||||
domain.Exports = ResolveExports(domain.SourceIds, domain.ExportFormat, request.Exports);
|
||||
}
|
||||
|
||||
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await domainStore.SaveDomainAsync(domain, ct);
|
||||
await domainStore.SaveDomainAsync(domain, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(MapDomainDetail(domain));
|
||||
return HttpResults.Ok(MapDomain(domain));
|
||||
})
|
||||
.WithName("UpdateMirrorDomain")
|
||||
.WithSummary("Update mirror domain configuration")
|
||||
.WithDescription("Updates the specified mirror domain. Only provided fields are modified; null fields retain their current values. Providing exports replaces the entire export list.")
|
||||
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
|
||||
.WithDescription("Updates the specified mirror domain using the UI contract and returns the updated operator view.")
|
||||
.Produces<MirrorDomainResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
// DELETE /domains/{domainId} — remove domain
|
||||
group.MapDelete("/domains/{domainId}", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
||||
group.MapDelete("/domains/{domainId}", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, [FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor, CancellationToken ct) =>
|
||||
{
|
||||
var domain = domainStore.GetDomain(domainId);
|
||||
if (domain is null)
|
||||
@@ -182,17 +197,57 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
||||
}
|
||||
|
||||
await domainStore.DeleteDomainAsync(domainId, ct);
|
||||
await domainStore.DeleteDomainAsync(domainId, ct).ConfigureAwait(false);
|
||||
DeleteMirrorArtifacts(domainId, optionsMonitor);
|
||||
await RefreshMirrorIndexAsync(domainStore, optionsMonitor, ct).ConfigureAwait(false);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteMirrorDomain")
|
||||
.WithSummary("Remove a mirror domain")
|
||||
.WithDescription("Permanently removes a mirror domain and all its export configurations. Active consumers will lose access immediately.")
|
||||
.WithDescription("Permanently removes a mirror domain and its generated mirror artifacts.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
// POST /domains/{domainId}/exports — add export to domain
|
||||
group.MapGet("/domains/{domainId}/config", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
|
||||
{
|
||||
var domain = domainStore.GetDomain(domainId);
|
||||
if (domain is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
||||
}
|
||||
|
||||
return HttpResults.Ok(MapDomainConfig(domain));
|
||||
})
|
||||
.WithName("GetMirrorDomainConfig")
|
||||
.WithSummary("Get the resolved domain configuration")
|
||||
.WithDescription("Returns the resolved domain configuration used by the mirror domain builder review step.")
|
||||
.Produces<MirrorDomainConfigResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
group.MapGet("/domains/{domainId}/endpoints", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
|
||||
{
|
||||
var domain = domainStore.GetDomain(domainId);
|
||||
if (domain is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new MirrorDomainEndpointsResponse
|
||||
{
|
||||
DomainId = domain.Id,
|
||||
Endpoints = BuildDomainEndpoints(domain),
|
||||
});
|
||||
})
|
||||
.WithName("GetMirrorDomainEndpoints")
|
||||
.WithSummary("List public endpoints for a mirror domain")
|
||||
.WithDescription("Returns the public mirror paths that an operator can hand to downstream consumers.")
|
||||
.Produces<MirrorDomainEndpointsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorReadPolicy);
|
||||
|
||||
group.MapPost("/domains/{domainId}/exports", async ([FromRoute] string domainId, [FromBody] CreateMirrorExportRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
||||
{
|
||||
var domain = domainStore.GetDomain(domainId);
|
||||
@@ -217,10 +272,14 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
Format = request.Format ?? "json",
|
||||
Filters = request.Filters ?? new Dictionary<string, string>(),
|
||||
});
|
||||
if (!domain.SourceIds.Contains(request.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
domain.SourceIds.Add(request.Key);
|
||||
}
|
||||
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await domainStore.SaveDomainAsync(domain, ct);
|
||||
return HttpResults.Created($"/api/v1/mirror/domains/{domainId}/exports/{request.Key}", new { domainId, exportKey = request.Key });
|
||||
await domainStore.SaveDomainAsync(domain, ct).ConfigureAwait(false);
|
||||
return HttpResults.Created($"{MirrorBasePath}/domains/{domainId}/exports/{request.Key}", new { domainId, exportKey = request.Key });
|
||||
})
|
||||
.WithName("AddMirrorExport")
|
||||
.WithSummary("Add an export to a mirror domain")
|
||||
@@ -245,8 +304,11 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.NotFound(new { error = "export_not_found", domainId, exportKey });
|
||||
}
|
||||
|
||||
domain.SourceIds = domain.SourceIds
|
||||
.Where(sourceId => !string.Equals(sourceId, exportKey, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await domainStore.SaveDomainAsync(domain, ct);
|
||||
await domainStore.SaveDomainAsync(domain, ct).ConfigureAwait(false);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("RemoveMirrorExport")
|
||||
@@ -257,7 +319,7 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
// POST /domains/{domainId}/generate — trigger bundle generation
|
||||
group.MapPost("/domains/{domainId}/generate", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
||||
group.MapPost("/domains/{domainId}/generate", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, [FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor, CancellationToken ct) =>
|
||||
{
|
||||
var domain = domainStore.GetDomain(domainId);
|
||||
if (domain is null)
|
||||
@@ -266,15 +328,23 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
}
|
||||
|
||||
// Mark generation as triggered — actual generation happens async
|
||||
domain.LastGenerateTriggeredAt = DateTimeOffset.UtcNow;
|
||||
await domainStore.SaveDomainAsync(domain, ct);
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
domain.LastGenerateTriggeredAt = startedAt;
|
||||
await GenerateMirrorArtifactsAsync(domainStore, domain, optionsMonitor, ct).ConfigureAwait(false);
|
||||
await domainStore.SaveDomainAsync(domain, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Accepted($"/api/v1/mirror/domains/{domainId}/status", new { domainId, status = "generation_triggered" });
|
||||
return HttpResults.Accepted($"{MirrorBasePath}/domains/{domainId}/status", new MirrorDomainGenerateResponse
|
||||
{
|
||||
DomainId = domain.Id,
|
||||
JobId = Guid.NewGuid().ToString("N"),
|
||||
Status = "generation_triggered",
|
||||
StartedAt = startedAt,
|
||||
});
|
||||
})
|
||||
.WithName("TriggerMirrorGeneration")
|
||||
.WithSummary("Trigger bundle generation for a mirror domain")
|
||||
.WithDescription("Triggers asynchronous bundle generation for the specified mirror domain. The generation status can be polled via the domain status endpoint.")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.WithSummary("Generate public mirror artifacts for a domain")
|
||||
.WithDescription("Generates the public index, manifest, and bundle files for the specified mirror domain using the configured export root.")
|
||||
.Produces<MirrorDomainGenerateResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
@@ -294,10 +364,8 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
LastGenerateTriggeredAt = domain.LastGenerateTriggeredAt,
|
||||
BundleSizeBytes = domain.BundleSizeBytes,
|
||||
AdvisoryCount = domain.AdvisoryCount,
|
||||
ExportCount = domain.Exports.Count,
|
||||
Staleness = domain.LastGeneratedAt.HasValue
|
||||
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
|
||||
: "never_generated",
|
||||
ExportCount = domain.SourceIds.Count,
|
||||
Staleness = ComputeDomainStaleness(domain),
|
||||
});
|
||||
})
|
||||
.WithName("GetMirrorDomainStatus")
|
||||
@@ -500,7 +568,7 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return HttpResults.Accepted($"/api/v1/mirror/import/status", new MirrorBundleImportAcceptedResponse
|
||||
return HttpResults.Accepted($"{MirrorBasePath}/import/status", new MirrorBundleImportAcceptedResponse
|
||||
{
|
||||
ImportId = importId,
|
||||
Status = "running",
|
||||
@@ -560,22 +628,35 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.BadRequest(new { error = "base_address_required" });
|
||||
}
|
||||
|
||||
var probeUrl = $"{request.BaseAddress.TrimEnd('/')}{MirrorIndexPath}";
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient("MirrorTest");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var response = await client.GetAsync(
|
||||
$"{request.BaseAddress.TrimEnd('/')}/domains",
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
ct);
|
||||
var response = await client.GetAsync(probeUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
var latencyMs = (int)Math.Round(System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return HttpResults.Ok(new MirrorTestResponse
|
||||
{
|
||||
Reachable = true,
|
||||
LatencyMs = latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new MirrorTestResponse
|
||||
{
|
||||
Reachable = response.IsSuccessStatusCode,
|
||||
StatusCode = (int)response.StatusCode,
|
||||
Message = response.IsSuccessStatusCode ? "Mirror endpoint is reachable" : $"Mirror returned {response.StatusCode}",
|
||||
Reachable = false,
|
||||
LatencyMs = latencyMs,
|
||||
Error = $"Mirror returned HTTP {(int)response.StatusCode} from {probeUrl}",
|
||||
Remediation = response.StatusCode == System.Net.HttpStatusCode.NotFound
|
||||
? $"Verify the upstream mirror publishes {MirrorIndexPath}."
|
||||
: "Verify the mirror URL, authentication requirements, and reverse-proxy exposure.",
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -583,13 +664,15 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.Ok(new MirrorTestResponse
|
||||
{
|
||||
Reachable = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
LatencyMs = 0,
|
||||
Error = $"Connection failed: {ex.Message}",
|
||||
Remediation = "Verify the mirror URL is correct and the upstream Stella Ops instance is reachable on the network.",
|
||||
});
|
||||
}
|
||||
})
|
||||
.WithName("TestMirrorEndpoint")
|
||||
.WithSummary("Test mirror consumer endpoint connectivity")
|
||||
.WithDescription("Sends a probe request to the specified mirror consumer base address and reports reachability, HTTP status code, and any connection errors.")
|
||||
.WithDescription("Sends a probe request to the specified mirror base address and reports reachability, latency, and remediation guidance.")
|
||||
.Produces<MirrorTestResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
|
||||
@@ -640,8 +723,8 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
}
|
||||
|
||||
var indexPath = string.IsNullOrWhiteSpace(request.IndexPath)
|
||||
? "/concelier/exports/index.json"
|
||||
: request.IndexPath.TrimStart('/');
|
||||
? MirrorIndexPath
|
||||
: request.IndexPath.Trim();
|
||||
|
||||
var indexUrl = $"{request.BaseAddress.TrimEnd('/')}/{indexPath.TrimStart('/')}";
|
||||
|
||||
@@ -726,7 +809,7 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
return HttpResults.BadRequest(new { error = "domain_id_required" });
|
||||
}
|
||||
|
||||
var bundleUrl = $"{request.BaseAddress.TrimEnd('/')}/{request.DomainId}/bundle.json.jws";
|
||||
var bundleUrl = $"{request.BaseAddress.TrimEnd('/')}{MirrorDomainRoot}/{request.DomainId}/bundle.json.jws";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -823,34 +906,322 @@ internal static class MirrorDomainManagementEndpointExtensions
|
||||
.RequireAuthorization(MirrorManagePolicy);
|
||||
}
|
||||
|
||||
private static MirrorDomainSummary MapDomainSummary(MirrorDomainRecord domain) => new()
|
||||
private static MirrorConfigResponse MapMirrorConfig(MirrorConfigRecord config, ConsumerConfigResponse consumer) => new()
|
||||
{
|
||||
Id = domain.Id,
|
||||
DisplayName = domain.DisplayName,
|
||||
ExportCount = domain.Exports.Count,
|
||||
LastGeneratedAt = domain.LastGeneratedAt,
|
||||
Staleness = domain.LastGeneratedAt.HasValue
|
||||
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
|
||||
: "never_generated",
|
||||
Mode = NormalizeMirrorMode(config.Mode),
|
||||
ConsumerMirrorUrl = consumer.BaseAddress ?? config.ConsumerBaseAddress,
|
||||
ConsumerConnected = consumer.Connected,
|
||||
LastConsumerSync = consumer.LastSync,
|
||||
};
|
||||
|
||||
private static MirrorDomainDetailResponse MapDomainDetail(MirrorDomainRecord domain) => new()
|
||||
private static MirrorDomainResponse MapDomain(MirrorDomainRecord domain) => new()
|
||||
{
|
||||
Id = domain.Id,
|
||||
DomainId = domain.Id,
|
||||
DisplayName = domain.DisplayName,
|
||||
RequireAuthentication = domain.RequireAuthentication,
|
||||
MaxIndexRequestsPerHour = domain.MaxIndexRequestsPerHour,
|
||||
MaxDownloadRequestsPerHour = domain.MaxDownloadRequestsPerHour,
|
||||
Exports = domain.Exports.Select(e => new MirrorExportSummary
|
||||
SourceIds = domain.SourceIds,
|
||||
ExportFormat = domain.ExportFormat,
|
||||
RateLimits = new MirrorDomainRateLimitsResponse
|
||||
{
|
||||
Key = e.Key,
|
||||
Format = e.Format,
|
||||
Filters = e.Filters,
|
||||
}).ToList(),
|
||||
LastGeneratedAt = domain.LastGeneratedAt,
|
||||
IndexRequestsPerHour = domain.MaxIndexRequestsPerHour,
|
||||
DownloadRequestsPerHour = domain.MaxDownloadRequestsPerHour,
|
||||
},
|
||||
RequireAuthentication = domain.RequireAuthentication,
|
||||
Signing = new MirrorDomainSigningResponse
|
||||
{
|
||||
Enabled = domain.SigningEnabled,
|
||||
Algorithm = domain.SigningAlgorithm,
|
||||
KeyId = domain.SigningKeyId,
|
||||
},
|
||||
DomainUrl = $"{MirrorDomainRoot}/{domain.Id}",
|
||||
CreatedAt = domain.CreatedAt,
|
||||
UpdatedAt = domain.UpdatedAt,
|
||||
Status = ComputeDomainStatus(domain),
|
||||
};
|
||||
|
||||
private static MirrorDomainConfigResponse MapDomainConfig(MirrorDomainRecord domain) => new()
|
||||
{
|
||||
DomainId = domain.Id,
|
||||
DisplayName = domain.DisplayName,
|
||||
SourceIds = domain.SourceIds,
|
||||
ExportFormat = domain.ExportFormat,
|
||||
RateLimits = new MirrorDomainRateLimitsResponse
|
||||
{
|
||||
IndexRequestsPerHour = domain.MaxIndexRequestsPerHour,
|
||||
DownloadRequestsPerHour = domain.MaxDownloadRequestsPerHour,
|
||||
},
|
||||
RequireAuthentication = domain.RequireAuthentication,
|
||||
Signing = new MirrorDomainSigningResponse
|
||||
{
|
||||
Enabled = domain.SigningEnabled,
|
||||
Algorithm = domain.SigningAlgorithm,
|
||||
KeyId = domain.SigningKeyId,
|
||||
},
|
||||
ResolvedFilter = new Dictionary<string, object?>
|
||||
{
|
||||
["domainId"] = domain.Id,
|
||||
["sourceIds"] = domain.SourceIds.ToArray(),
|
||||
["exportFormat"] = domain.ExportFormat,
|
||||
["rateLimits"] = new Dictionary<string, object?>
|
||||
{
|
||||
["indexRequestsPerHour"] = domain.MaxIndexRequestsPerHour,
|
||||
["downloadRequestsPerHour"] = domain.MaxDownloadRequestsPerHour,
|
||||
},
|
||||
["requireAuthentication"] = domain.RequireAuthentication,
|
||||
["signing"] = new Dictionary<string, object?>
|
||||
{
|
||||
["enabled"] = domain.SigningEnabled,
|
||||
["algorithm"] = domain.SigningAlgorithm,
|
||||
["keyId"] = domain.SigningKeyId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static IReadOnlyList<MirrorDomainEndpointDto> BuildDomainEndpoints(MirrorDomainRecord domain)
|
||||
{
|
||||
var endpoints = new List<MirrorDomainEndpointDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Method = "GET",
|
||||
Path = MirrorIndexPath,
|
||||
Description = "Mirror index used by downstream discovery clients.",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Method = "GET",
|
||||
Path = $"{MirrorDomainRoot}/{domain.Id}/manifest.json",
|
||||
Description = "Domain manifest describing the generated advisory bundle.",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Method = "GET",
|
||||
Path = $"{MirrorDomainRoot}/{domain.Id}/bundle.json",
|
||||
Description = "Generated advisory bundle payload for the mirror domain.",
|
||||
},
|
||||
};
|
||||
|
||||
if (domain.SigningEnabled)
|
||||
{
|
||||
endpoints.Add(new MirrorDomainEndpointDto
|
||||
{
|
||||
Method = "GET",
|
||||
Path = $"{MirrorDomainRoot}/{domain.Id}/bundle.json.jws",
|
||||
Description = "Detached JWS envelope path used for signature discovery when signing is available.",
|
||||
});
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static string ComputeDomainStaleness(MirrorDomainRecord domain)
|
||||
{
|
||||
if (!domain.LastGeneratedAt.HasValue)
|
||||
{
|
||||
return "never_generated";
|
||||
}
|
||||
|
||||
return (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120
|
||||
? "stale"
|
||||
: "fresh";
|
||||
}
|
||||
|
||||
private static string ComputeDomainStatus(MirrorDomainRecord domain) => ComputeDomainStaleness(domain) switch
|
||||
{
|
||||
"fresh" => "Fresh",
|
||||
"stale" => "Stale",
|
||||
_ => "Never generated",
|
||||
};
|
||||
|
||||
private static string NormalizeDomainId(string? domainId)
|
||||
=> string.IsNullOrWhiteSpace(domainId)
|
||||
? string.Empty
|
||||
: domainId.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeMirrorMode(string? mode)
|
||||
=> string.IsNullOrWhiteSpace(mode)
|
||||
? "Direct"
|
||||
: mode.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"mirror" => "Mirror",
|
||||
"hybrid" => "Hybrid",
|
||||
_ => "Direct",
|
||||
};
|
||||
|
||||
private static List<string> ResolveSourceIds(IReadOnlyList<string>? sourceIds, IReadOnlyList<CreateMirrorExportRequest>? exports, IReadOnlyList<string>? existing = null)
|
||||
{
|
||||
IEnumerable<string> values =
|
||||
sourceIds?.Where(candidate => !string.IsNullOrWhiteSpace(candidate)).Select(candidate => candidate.Trim())
|
||||
?? exports?.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Key)).Select(candidate => candidate.Key.Trim())
|
||||
?? existing?.Where(candidate => !string.IsNullOrWhiteSpace(candidate)).Select(candidate => candidate.Trim())
|
||||
?? [];
|
||||
|
||||
return values
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<MirrorExportRecord> ResolveExports(IReadOnlyList<string> sourceIds, string exportFormat, IReadOnlyList<CreateMirrorExportRequest>? requestExports)
|
||||
{
|
||||
if (requestExports is not null && requestExports.Count > 0)
|
||||
{
|
||||
return requestExports.Select(exportRequest => new MirrorExportRecord
|
||||
{
|
||||
Key = exportRequest.Key,
|
||||
Format = exportRequest.Format ?? exportFormat,
|
||||
Filters = exportRequest.Filters ?? new Dictionary<string, string>(),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
return sourceIds.Select(sourceId => new MirrorExportRecord
|
||||
{
|
||||
Key = sourceId,
|
||||
Format = exportFormat,
|
||||
Filters = new Dictionary<string, string>(),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static async Task GenerateMirrorArtifactsAsync(IMirrorDomainStore domainStore, MirrorDomainRecord domain, IOptionsMonitor<ConcelierOptions> optionsMonitor, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetMirrorPaths(optionsMonitor.CurrentValue.Mirror, domain.Id, out var mirrorRoot, out var domainRoot, out var indexPath, out var manifestPath, out var bundlePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(mirrorRoot);
|
||||
Directory.CreateDirectory(domainRoot);
|
||||
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
var bundleDocument = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
generatedAt,
|
||||
domainId = domain.Id,
|
||||
displayName = domain.DisplayName,
|
||||
exportFormat = domain.ExportFormat,
|
||||
sourceIds = domain.SourceIds,
|
||||
advisories = Array.Empty<object>(),
|
||||
};
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
var bundleJson = JsonSerializer.Serialize(bundleDocument, jsonOptions);
|
||||
await File.WriteAllTextAsync(bundlePath, bundleJson, ct).ConfigureAwait(false);
|
||||
|
||||
var bundleBytes = System.Text.Encoding.UTF8.GetBytes(bundleJson);
|
||||
var manifest = new MirrorBundleManifestDto
|
||||
{
|
||||
DomainId = domain.Id,
|
||||
DisplayName = domain.DisplayName,
|
||||
GeneratedAt = generatedAt,
|
||||
Exports =
|
||||
[
|
||||
new MirrorBundleExportDto
|
||||
{
|
||||
Key = domain.Id,
|
||||
ExportId = domain.Id,
|
||||
Format = domain.ExportFormat,
|
||||
ArtifactSizeBytes = bundleBytes.Length,
|
||||
ArtifactDigest = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bundleBytes)).ToLowerInvariant()}",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, jsonOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, ct).ConfigureAwait(false);
|
||||
|
||||
domain.LastGeneratedAt = generatedAt;
|
||||
domain.BundleSizeBytes = bundleBytes.Length;
|
||||
domain.AdvisoryCount = 0;
|
||||
domain.UpdatedAt = generatedAt;
|
||||
|
||||
await RefreshMirrorIndexAsync(domainStore, optionsMonitor, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RefreshMirrorIndexAsync(IMirrorDomainStore domainStore, IOptionsMonitor<ConcelierOptions> optionsMonitor, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetMirrorPaths(optionsMonitor.CurrentValue.Mirror, string.Empty, out var mirrorRoot, out _, out var indexPath, out _, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(mirrorRoot);
|
||||
|
||||
var domains = domainStore.GetAllDomains();
|
||||
var indexDocument = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
domains = domains
|
||||
.Where(domain => domain.LastGeneratedAt.HasValue)
|
||||
.Select(domain => new
|
||||
{
|
||||
domainId = domain.Id,
|
||||
displayName = domain.DisplayName,
|
||||
lastGenerated = domain.LastGeneratedAt,
|
||||
advisoryCount = domain.AdvisoryCount,
|
||||
bundleSize = domain.BundleSizeBytes,
|
||||
exportFormats = new[] { domain.ExportFormat },
|
||||
signed = File.Exists(Path.Combine(mirrorRoot, domain.Id, "bundle.json.jws")),
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(indexDocument, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
});
|
||||
await File.WriteAllTextAsync(indexPath, json, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void DeleteMirrorArtifacts(string domainId, IOptionsMonitor<ConcelierOptions> optionsMonitor)
|
||||
{
|
||||
if (!TryGetMirrorPaths(optionsMonitor.CurrentValue.Mirror, domainId, out _, out var domainRoot, out _, out _, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.Exists(domainRoot))
|
||||
{
|
||||
Directory.Delete(domainRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetMirrorPaths(
|
||||
ConcelierOptions.MirrorOptions? mirrorOptions,
|
||||
string domainId,
|
||||
out string mirrorRoot,
|
||||
out string domainRoot,
|
||||
out string indexPath,
|
||||
out string manifestPath,
|
||||
out string bundlePath)
|
||||
{
|
||||
mirrorRoot = string.Empty;
|
||||
domainRoot = string.Empty;
|
||||
indexPath = string.Empty;
|
||||
manifestPath = string.Empty;
|
||||
bundlePath = string.Empty;
|
||||
|
||||
if (mirrorOptions is null || !mirrorOptions.Enabled || string.IsNullOrWhiteSpace(mirrorOptions.ExportRootAbsolute))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var exportId = string.IsNullOrWhiteSpace(mirrorOptions.ActiveExportId)
|
||||
? mirrorOptions.LatestDirectoryName
|
||||
: mirrorOptions.ActiveExportId!;
|
||||
var exportRoot = Path.Combine(mirrorOptions.ExportRootAbsolute, exportId);
|
||||
mirrorRoot = Path.Combine(exportRoot, mirrorOptions.MirrorDirectoryName);
|
||||
domainRoot = string.IsNullOrWhiteSpace(domainId)
|
||||
? string.Empty
|
||||
: Path.Combine(mirrorRoot, domainId);
|
||||
indexPath = Path.Combine(mirrorRoot, "index.json");
|
||||
manifestPath = string.IsNullOrWhiteSpace(domainRoot) ? string.Empty : Path.Combine(domainRoot, "manifest.json");
|
||||
bundlePath = string.IsNullOrWhiteSpace(domainRoot) ? string.Empty : Path.Combine(domainRoot, "bundle.json");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Interfaces =====
|
||||
@@ -872,6 +1243,7 @@ public interface IMirrorDomainStore
|
||||
/// </summary>
|
||||
public interface IMirrorConfigStore
|
||||
{
|
||||
MirrorConfigRecord GetConfig();
|
||||
Task UpdateConfigAsync(UpdateMirrorConfigRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -900,9 +1272,14 @@ public sealed class MirrorDomainRecord
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public List<string> SourceIds { get; set; } = [];
|
||||
public string ExportFormat { get; set; } = "JSON";
|
||||
public bool RequireAuthentication { get; set; }
|
||||
public int MaxIndexRequestsPerHour { get; set; } = 120;
|
||||
public int MaxDownloadRequestsPerHour { get; set; } = 600;
|
||||
public bool SigningEnabled { get; set; }
|
||||
public string SigningAlgorithm { get; set; } = "HMAC-SHA256";
|
||||
public string SigningKeyId { get; set; } = string.Empty;
|
||||
public List<MirrorExportRecord> Exports { get; set; } = [];
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
@@ -919,6 +1296,18 @@ public sealed class MirrorExportRecord
|
||||
public Dictionary<string, string> Filters { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MirrorConfigRecord
|
||||
{
|
||||
public string Mode { get; set; } = "Direct";
|
||||
public string? OutputRoot { get; set; }
|
||||
public string? ConsumerBaseAddress { get; set; }
|
||||
public bool SigningEnabled { get; set; }
|
||||
public string? SigningAlgorithm { get; set; }
|
||||
public string? SigningKeyId { get; set; }
|
||||
public bool AutoRefreshEnabled { get; set; } = true;
|
||||
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class MirrorConfigOptions
|
||||
{
|
||||
public string Mode { get; set; } = "direct";
|
||||
@@ -951,9 +1340,14 @@ public sealed record MirrorSigningRequest
|
||||
|
||||
public sealed record CreateMirrorDomainRequest
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Id { get; init; }
|
||||
public string? DomainId { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
public string? ExportFormat { get; init; }
|
||||
public MirrorDomainRateLimitsRequest? RateLimits { get; init; }
|
||||
public bool RequireAuthentication { get; init; }
|
||||
public MirrorDomainSigningRequest? Signing { get; init; }
|
||||
public int? MaxIndexRequestsPerHour { get; init; }
|
||||
public int? MaxDownloadRequestsPerHour { get; init; }
|
||||
public List<CreateMirrorExportRequest>? Exports { get; init; }
|
||||
@@ -962,12 +1356,29 @@ public sealed record CreateMirrorDomainRequest
|
||||
public sealed record UpdateMirrorDomainRequest
|
||||
{
|
||||
public string? DisplayName { get; init; }
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
public string? ExportFormat { get; init; }
|
||||
public MirrorDomainRateLimitsRequest? RateLimits { get; init; }
|
||||
public bool? RequireAuthentication { get; init; }
|
||||
public MirrorDomainSigningRequest? Signing { get; init; }
|
||||
public int? MaxIndexRequestsPerHour { get; init; }
|
||||
public int? MaxDownloadRequestsPerHour { get; init; }
|
||||
public List<CreateMirrorExportRequest>? Exports { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainRateLimitsRequest
|
||||
{
|
||||
public int IndexRequestsPerHour { get; init; }
|
||||
public int DownloadRequestsPerHour { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainSigningRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
public string? Algorithm { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CreateMirrorExportRequest
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
@@ -1014,12 +1425,10 @@ public sealed record ConsumerVerifySignatureRequest
|
||||
|
||||
public sealed record MirrorConfigResponse
|
||||
{
|
||||
public string Mode { get; init; } = "direct";
|
||||
public string? OutputRoot { get; init; }
|
||||
public string? ConsumerBaseAddress { get; init; }
|
||||
public MirrorSigningResponse? Signing { get; init; }
|
||||
public bool AutoRefreshEnabled { get; init; }
|
||||
public int RefreshIntervalMinutes { get; init; }
|
||||
public string Mode { get; init; } = "Direct";
|
||||
public string? ConsumerMirrorUrl { get; init; }
|
||||
public bool ConsumerConnected { get; init; }
|
||||
public DateTimeOffset? LastConsumerSync { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorSigningResponse
|
||||
@@ -1031,37 +1440,78 @@ public sealed record MirrorSigningResponse
|
||||
|
||||
public sealed record MirrorDomainListResponse
|
||||
{
|
||||
public IReadOnlyList<MirrorDomainSummary> Domains { get; init; } = [];
|
||||
public IReadOnlyList<MirrorDomainResponse> Domains { get; init; } = [];
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainSummary
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string DisplayName { get; init; } = "";
|
||||
public int ExportCount { get; init; }
|
||||
public DateTimeOffset? LastGeneratedAt { get; init; }
|
||||
public string Staleness { get; init; } = "never_generated";
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainDetailResponse
|
||||
public sealed record MirrorDomainResponse
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string DomainId { get; init; } = "";
|
||||
public string DisplayName { get; init; } = "";
|
||||
public IReadOnlyList<string> SourceIds { get; init; } = [];
|
||||
public string ExportFormat { get; init; } = "JSON";
|
||||
public MirrorDomainRateLimitsResponse RateLimits { get; init; } = new();
|
||||
public bool RequireAuthentication { get; init; }
|
||||
public int MaxIndexRequestsPerHour { get; init; }
|
||||
public int MaxDownloadRequestsPerHour { get; init; }
|
||||
public IReadOnlyList<MirrorExportSummary> Exports { get; init; } = [];
|
||||
public DateTimeOffset? LastGeneratedAt { get; init; }
|
||||
public MirrorDomainSigningResponse Signing { get; init; } = new();
|
||||
public string DomainUrl { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
public string Status { get; init; } = "Never generated";
|
||||
}
|
||||
|
||||
public sealed record MirrorExportSummary
|
||||
public sealed record MirrorDomainConfigResponse
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public string Format { get; init; } = "json";
|
||||
public Dictionary<string, string> Filters { get; init; } = new();
|
||||
public string DomainId { get; init; } = "";
|
||||
public string DisplayName { get; init; } = "";
|
||||
public IReadOnlyList<string> SourceIds { get; init; } = [];
|
||||
public string ExportFormat { get; init; } = "JSON";
|
||||
public MirrorDomainRateLimitsResponse RateLimits { get; init; } = new();
|
||||
public bool RequireAuthentication { get; init; }
|
||||
public MirrorDomainSigningResponse Signing { get; init; } = new();
|
||||
public IReadOnlyDictionary<string, object?> ResolvedFilter { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainRateLimitsResponse
|
||||
{
|
||||
public int IndexRequestsPerHour { get; init; }
|
||||
public int DownloadRequestsPerHour { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainSigningResponse
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record MirrorHealthSummary
|
||||
{
|
||||
public int TotalDomains { get; init; }
|
||||
public int FreshCount { get; init; }
|
||||
public int StaleCount { get; init; }
|
||||
public int NeverGeneratedCount { get; init; }
|
||||
public long TotalAdvisoryCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainEndpointsResponse
|
||||
{
|
||||
public string DomainId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<MirrorDomainEndpointDto> Endpoints { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainEndpointDto
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string Method { get; init; } = "GET";
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainGenerateResponse
|
||||
{
|
||||
public string DomainId { get; init; } = string.Empty;
|
||||
public string JobId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "generation_triggered";
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MirrorDomainStatusResponse
|
||||
@@ -1078,8 +1528,9 @@ public sealed record MirrorDomainStatusResponse
|
||||
public sealed record MirrorTestResponse
|
||||
{
|
||||
public bool Reachable { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public int LatencyMs { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? Remediation { get; init; }
|
||||
}
|
||||
|
||||
// ===== Consumer connector DTOs =====
|
||||
|
||||
@@ -60,6 +60,7 @@ internal static class MirrorEndpointExtensions
|
||||
string? relativePath,
|
||||
[FromServices] MirrorFileLocator locator,
|
||||
[FromServices] MirrorRateLimiter limiter,
|
||||
[FromServices] IMirrorDomainStore domainStore,
|
||||
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -80,15 +81,25 @@ internal static class MirrorEndpointExtensions
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
|
||||
}
|
||||
|
||||
var domain = FindDomain(mirrorOptions, domainId);
|
||||
var managedDomain = string.IsNullOrWhiteSpace(domainId) ? null : domainStore.GetDomain(domainId);
|
||||
var configuredDomain = FindDomain(mirrorOptions, domainId);
|
||||
var requireAuthentication =
|
||||
managedDomain?.RequireAuthentication ??
|
||||
configuredDomain?.RequireAuthentication ??
|
||||
mirrorOptions.RequireAuthentication;
|
||||
|
||||
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
if (!TryAuthorize(requireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
|
||||
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
|
||||
var defaultDownloadLimit = new ConcelierOptions.MirrorDomainOptions().MaxDownloadRequestsPerHour;
|
||||
var limit =
|
||||
managedDomain?.MaxDownloadRequestsPerHour ??
|
||||
configuredDomain?.MaxDownloadRequestsPerHour ??
|
||||
defaultDownloadLimit;
|
||||
var limiterKey = managedDomain?.Id ?? configuredDomain?.Id ?? "__mirror__";
|
||||
if (!limiter.TryAcquire(limiterKey, DownloadScope, limit, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
|
||||
@@ -16,7 +16,7 @@ internal static class SourceManagementEndpointExtensions
|
||||
|
||||
public static void MapSourceManagementEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/sources")
|
||||
var group = app.MapGroup("/api/v1/advisory-sources")
|
||||
.WithTags("Source Management")
|
||||
.RequireTenant();
|
||||
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Rename;
|
||||
using EnvModels = StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for release topology setup: regions, infrastructure bindings,
|
||||
/// readiness validation, rename operations, and deletion lifecycle.
|
||||
/// </summary>
|
||||
internal static class TopologySetupEndpointExtensions
|
||||
{
|
||||
private const string TopologyManagePolicy = "Topology.Manage";
|
||||
private const string TopologyReadPolicy = "Topology.Read";
|
||||
private const string TopologyAdminPolicy = "Topology.Admin";
|
||||
|
||||
public static void MapTopologySetupEndpoints(this WebApplication app)
|
||||
{
|
||||
MapRegionEndpoints(app);
|
||||
MapInfrastructureBindingEndpoints(app);
|
||||
MapReadinessEndpoints(app);
|
||||
MapRenameEndpoints(app);
|
||||
MapDeletionEndpoints(app);
|
||||
}
|
||||
|
||||
// ── Region Endpoints ────────────────────────────────────────
|
||||
|
||||
private static void MapRegionEndpoints(WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/regions")
|
||||
.WithTags("Regions");
|
||||
|
||||
group.MapPost("/", async (
|
||||
[FromBody] CreateRegionApiRequest body,
|
||||
[FromServices] IRegionService regionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var region = await regionService.CreateAsync(new CreateRegionRequest(
|
||||
body.Name, body.DisplayName, body.Description,
|
||||
body.CryptoProfile ?? "international", body.SortOrder ?? 0), ct);
|
||||
return HttpResults.Created($"/api/v1/regions/{region.Id}", MapRegion(region));
|
||||
})
|
||||
.WithName("CreateRegion")
|
||||
.WithSummary("Create a new region")
|
||||
.Produces<RegionResponse>(StatusCodes.Status201Created)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
|
||||
group.MapGet("/", async (
|
||||
[FromServices] IRegionService regionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var regions = await regionService.ListAsync(ct);
|
||||
return HttpResults.Ok(new RegionListResponse
|
||||
{
|
||||
Items = regions.Select(MapRegion).ToList(),
|
||||
TotalCount = regions.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListRegions")
|
||||
.WithSummary("List all regions for the current tenant")
|
||||
.Produces<RegionListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
group.MapGet("/{id:guid}", async (
|
||||
Guid id,
|
||||
[FromServices] IRegionService regionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var region = await regionService.GetAsync(id, ct);
|
||||
return region is not null
|
||||
? HttpResults.Ok(MapRegion(region))
|
||||
: HttpResults.NotFound();
|
||||
})
|
||||
.WithName("GetRegion")
|
||||
.WithSummary("Get a region by ID")
|
||||
.Produces<RegionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
group.MapPut("/{id:guid}", async (
|
||||
Guid id,
|
||||
[FromBody] UpdateRegionApiRequest body,
|
||||
[FromServices] IRegionService regionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var region = await regionService.UpdateAsync(id, new UpdateRegionRequest(
|
||||
body.DisplayName, body.Description, body.CryptoProfile, body.SortOrder, body.Status), ct);
|
||||
return HttpResults.Ok(MapRegion(region));
|
||||
})
|
||||
.WithName("UpdateRegion")
|
||||
.WithSummary("Update a region")
|
||||
.Produces<RegionResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
|
||||
group.MapDelete("/{id:guid}", async (
|
||||
Guid id,
|
||||
[FromServices] IRegionService regionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await regionService.DeleteAsync(id, ct);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteRegion")
|
||||
.WithSummary("Delete a region")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.RequireAuthorization(TopologyAdminPolicy);
|
||||
}
|
||||
|
||||
// ── Infrastructure Binding Endpoints ─────────────────────────
|
||||
|
||||
private static void MapInfrastructureBindingEndpoints(WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/infrastructure-bindings")
|
||||
.WithTags("Infrastructure Bindings");
|
||||
|
||||
group.MapPost("/", async (
|
||||
[FromBody] BindInfrastructureApiRequest body,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var scopeType = Enum.Parse<EnvModels.BindingScopeType>(body.ScopeType, ignoreCase: true);
|
||||
var role = Enum.Parse<EnvModels.BindingRole>(body.BindingRole, ignoreCase: true);
|
||||
var binding = await bindingService.BindAsync(new BindInfrastructureRequest(
|
||||
body.IntegrationId, scopeType, body.ScopeId, role, body.Priority ?? 0), ct);
|
||||
return HttpResults.Created($"/api/v1/infrastructure-bindings/{binding.Id}", MapBinding(binding));
|
||||
})
|
||||
.WithName("CreateInfrastructureBinding")
|
||||
.WithSummary("Bind an integration to a scope")
|
||||
.Produces<InfrastructureBindingResponse>(StatusCodes.Status201Created)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
|
||||
group.MapDelete("/{id:guid}", async (
|
||||
Guid id,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await bindingService.UnbindAsync(id, ct);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteInfrastructureBinding")
|
||||
.WithSummary("Remove an infrastructure binding")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
|
||||
group.MapGet("/", async (
|
||||
[FromQuery] string scopeType,
|
||||
[FromQuery] Guid? scopeId,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var scope = Enum.Parse<EnvModels.BindingScopeType>(scopeType, ignoreCase: true);
|
||||
var bindings = await bindingService.ListByScopeAsync(scope, scopeId, ct);
|
||||
return HttpResults.Ok(new InfrastructureBindingListResponse
|
||||
{
|
||||
Items = bindings.Select(MapBinding).ToList(),
|
||||
TotalCount = bindings.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListInfrastructureBindings")
|
||||
.WithSummary("List bindings by scope")
|
||||
.Produces<InfrastructureBindingListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
group.MapGet("/resolve", async (
|
||||
[FromQuery] Guid environmentId,
|
||||
[FromQuery] string role,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var bindingRole = Enum.Parse<EnvModels.BindingRole>(role, ignoreCase: true);
|
||||
var binding = await bindingService.ResolveAsync(environmentId, bindingRole, ct);
|
||||
return binding is not null
|
||||
? HttpResults.Ok(MapBinding(binding))
|
||||
: HttpResults.NotFound();
|
||||
})
|
||||
.WithName("ResolveInfrastructureBinding")
|
||||
.WithSummary("Resolve a binding with inheritance cascade")
|
||||
.Produces<InfrastructureBindingResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
group.MapGet("/resolve-all", async (
|
||||
[FromQuery] Guid environmentId,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resolution = await bindingService.ResolveAllAsync(environmentId, ct);
|
||||
return HttpResults.Ok(MapResolution(resolution));
|
||||
})
|
||||
.WithName("ResolveAllInfrastructureBindings")
|
||||
.WithSummary("Resolve all binding roles with inheritance")
|
||||
.Produces<InfrastructureBindingResolutionResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
group.MapPost("/{id:guid}/test", async (
|
||||
Guid id,
|
||||
[FromServices] IInfrastructureBindingService bindingService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await bindingService.TestBindingAsync(id, ct);
|
||||
return HttpResults.Ok(result);
|
||||
})
|
||||
.WithName("TestInfrastructureBinding")
|
||||
.WithSummary("Test binding connectivity")
|
||||
.Produces<object>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
}
|
||||
|
||||
// ── Readiness Endpoints ──────────────────────────────────────
|
||||
|
||||
private static void MapReadinessEndpoints(WebApplication app)
|
||||
{
|
||||
var targets = app.MapGroup("/api/v1/targets")
|
||||
.WithTags("Topology Readiness");
|
||||
|
||||
targets.MapPost("/{id:guid}/validate", async (
|
||||
Guid id,
|
||||
[FromServices] ITopologyReadinessService readinessService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var report = await readinessService.ValidateAsync(id, ct);
|
||||
return HttpResults.Ok(MapReport(report));
|
||||
})
|
||||
.WithName("ValidateTarget")
|
||||
.WithSummary("Run all readiness gates for a target")
|
||||
.Produces<TopologyPointReportResponse>(StatusCodes.Status200OK);
|
||||
|
||||
targets.MapGet("/{id:guid}/readiness", async (
|
||||
Guid id,
|
||||
[FromServices] ITopologyReadinessService readinessService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var report = await readinessService.GetLatestAsync(id, ct);
|
||||
return report is not null
|
||||
? HttpResults.Ok(MapReport(report))
|
||||
: HttpResults.NotFound();
|
||||
})
|
||||
.WithName("GetTargetReadiness")
|
||||
.WithSummary("Get latest readiness report for a target")
|
||||
.Produces<TopologyPointReportResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
var envs = app.MapGroup("/api/v1/environments")
|
||||
.WithTags("Topology Readiness");
|
||||
|
||||
envs.MapGet("/{id:guid}/readiness", async (
|
||||
Guid id,
|
||||
[FromServices] ITopologyReadinessService readinessService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var reports = await readinessService.ListByEnvironmentAsync(id, ct);
|
||||
return HttpResults.Ok(new ReadinessListResponse
|
||||
{
|
||||
Items = reports.Select(MapReport).ToList(),
|
||||
TotalCount = reports.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetEnvironmentReadiness")
|
||||
.WithSummary("Get readiness for all targets in an environment")
|
||||
.Produces<ReadinessListResponse>(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// ── Rename Endpoints ─────────────────────────────────────────
|
||||
|
||||
private static void MapRenameEndpoints(WebApplication app)
|
||||
{
|
||||
var entityTypes = new Dictionary<string, RenameEntityType>
|
||||
{
|
||||
["regions"] = RenameEntityType.Region,
|
||||
["environments"] = RenameEntityType.Environment,
|
||||
["targets"] = RenameEntityType.Target,
|
||||
["agents"] = RenameEntityType.Agent,
|
||||
["integrations"] = RenameEntityType.Integration
|
||||
};
|
||||
|
||||
foreach (var (path, entityType) in entityTypes)
|
||||
{
|
||||
app.MapPatch($"/api/v1/{path}/{{id:guid}}/name", async (
|
||||
Guid id,
|
||||
[FromBody] RenameApiRequest body,
|
||||
[FromServices] ITopologyRenameService renameService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await renameService.RenameAsync(
|
||||
new RenameRequest(entityType, id, body.Name, body.DisplayName), ct);
|
||||
|
||||
if (result.Success)
|
||||
return HttpResults.Ok(result);
|
||||
|
||||
if (result.Error == "name_conflict")
|
||||
return HttpResults.Conflict(result);
|
||||
|
||||
return HttpResults.BadRequest(result);
|
||||
})
|
||||
.WithName($"Rename{entityType}")
|
||||
.WithSummary($"Rename a {entityType.ToString().ToLowerInvariant()}")
|
||||
.WithTags("Topology Rename")
|
||||
.Produces<RenameResult>(StatusCodes.Status200OK)
|
||||
.Produces<RenameResult>(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deletion Endpoints ───────────────────────────────────────
|
||||
|
||||
private static void MapDeletionEndpoints(WebApplication app)
|
||||
{
|
||||
var entityPaths = new Dictionary<string, EnvModels.DeletionEntityType>
|
||||
{
|
||||
["regions"] = EnvModels.DeletionEntityType.Region,
|
||||
["environments"] = EnvModels.DeletionEntityType.Environment,
|
||||
["targets"] = EnvModels.DeletionEntityType.Target,
|
||||
["agents"] = EnvModels.DeletionEntityType.Agent,
|
||||
["integrations"] = EnvModels.DeletionEntityType.Integration
|
||||
};
|
||||
|
||||
// Request deletion for each entity type
|
||||
foreach (var (path, entityType) in entityPaths)
|
||||
{
|
||||
app.MapPost($"/api/v1/{path}/{{id:guid}}/request-delete", async (
|
||||
Guid id,
|
||||
[FromBody] RequestDeleteApiRequest? body,
|
||||
[FromServices] IPendingDeletionService deletionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var deletion = await deletionService.RequestDeletionAsync(
|
||||
new DeletionRequest(entityType, id, body?.Reason), ct);
|
||||
return HttpResults.Accepted($"/api/v1/pending-deletions/{deletion.Id}", MapDeletion(deletion));
|
||||
})
|
||||
.WithName($"RequestDelete{entityType}")
|
||||
.WithSummary($"Request deletion of a {entityType.ToString().ToLowerInvariant()} with cool-off")
|
||||
.WithTags("Topology Deletion")
|
||||
.Produces<PendingDeletionResponse>(StatusCodes.Status202Accepted)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
}
|
||||
|
||||
// Pending deletion management
|
||||
var deletionGroup = app.MapGroup("/api/v1/pending-deletions")
|
||||
.WithTags("Topology Deletion");
|
||||
|
||||
deletionGroup.MapPost("/{id:guid}/confirm", async (
|
||||
Guid id,
|
||||
[FromServices] IPendingDeletionService deletionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// In real impl, get confirmedBy from the current user context
|
||||
var deletion = await deletionService.ConfirmDeletionAsync(id, Guid.Empty, ct);
|
||||
return HttpResults.Ok(MapDeletion(deletion));
|
||||
})
|
||||
.WithName("ConfirmDeletion")
|
||||
.WithSummary("Confirm a pending deletion after cool-off expires")
|
||||
.Produces<PendingDeletionResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyAdminPolicy);
|
||||
|
||||
deletionGroup.MapPost("/{id:guid}/cancel", async (
|
||||
Guid id,
|
||||
[FromServices] IPendingDeletionService deletionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await deletionService.CancelDeletionAsync(id, Guid.Empty, ct);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("CancelDeletion")
|
||||
.WithSummary("Cancel a pending deletion")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.RequireAuthorization(TopologyManagePolicy);
|
||||
|
||||
deletionGroup.MapGet("/", async (
|
||||
[FromServices] IPendingDeletionService deletionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var deletions = await deletionService.ListPendingAsync(ct);
|
||||
return HttpResults.Ok(new PendingDeletionListResponse
|
||||
{
|
||||
Items = deletions.Select(MapDeletion).ToList(),
|
||||
TotalCount = deletions.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListPendingDeletions")
|
||||
.WithSummary("List all pending deletions for the current tenant")
|
||||
.Produces<PendingDeletionListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
|
||||
deletionGroup.MapGet("/{id:guid}", async (
|
||||
Guid id,
|
||||
[FromServices] IPendingDeletionService deletionService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var deletion = await deletionService.GetAsync(id, ct);
|
||||
return deletion is not null
|
||||
? HttpResults.Ok(MapDeletion(deletion))
|
||||
: HttpResults.NotFound();
|
||||
})
|
||||
.WithName("GetPendingDeletion")
|
||||
.WithSummary("Get pending deletion details with cascade summary")
|
||||
.Produces<PendingDeletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(TopologyReadPolicy);
|
||||
}
|
||||
|
||||
// ── Mappers ──────────────────────────────────────────────────
|
||||
|
||||
private static RegionResponse MapRegion(EnvModels.Region r) => new()
|
||||
{
|
||||
Id = r.Id,
|
||||
TenantId = r.TenantId,
|
||||
Name = r.Name,
|
||||
DisplayName = r.DisplayName,
|
||||
Description = r.Description,
|
||||
CryptoProfile = r.CryptoProfile,
|
||||
SortOrder = r.SortOrder,
|
||||
Status = r.Status.ToString().ToLowerInvariant(),
|
||||
CreatedAt = r.CreatedAt,
|
||||
UpdatedAt = r.UpdatedAt
|
||||
};
|
||||
|
||||
private static InfrastructureBindingResponse MapBinding(EnvModels.InfrastructureBinding b) => new()
|
||||
{
|
||||
Id = b.Id,
|
||||
IntegrationId = b.IntegrationId,
|
||||
ScopeType = b.ScopeType.ToString().ToLowerInvariant(),
|
||||
ScopeId = b.ScopeId,
|
||||
BindingRole = b.Role.ToString().ToLowerInvariant(),
|
||||
Priority = b.Priority,
|
||||
IsActive = b.IsActive,
|
||||
CreatedAt = b.CreatedAt
|
||||
};
|
||||
|
||||
private static InfrastructureBindingResolutionResponse MapResolution(EnvModels.InfrastructureBindingResolution r) => new()
|
||||
{
|
||||
Registry = r.Registry is not null ? new ResolvedBindingResponse
|
||||
{
|
||||
Binding = MapBinding(r.Registry.Binding),
|
||||
ResolvedFrom = r.Registry.ResolvedFrom.ToString().ToLowerInvariant()
|
||||
} : null,
|
||||
Vault = r.Vault is not null ? new ResolvedBindingResponse
|
||||
{
|
||||
Binding = MapBinding(r.Vault.Binding),
|
||||
ResolvedFrom = r.Vault.ResolvedFrom.ToString().ToLowerInvariant()
|
||||
} : null,
|
||||
SettingsStore = r.SettingsStore is not null ? new ResolvedBindingResponse
|
||||
{
|
||||
Binding = MapBinding(r.SettingsStore.Binding),
|
||||
ResolvedFrom = r.SettingsStore.ResolvedFrom.ToString().ToLowerInvariant()
|
||||
} : null
|
||||
};
|
||||
|
||||
private static TopologyPointReportResponse MapReport(EnvModels.TopologyPointReport r) => new()
|
||||
{
|
||||
TargetId = r.TargetId,
|
||||
EnvironmentId = r.EnvironmentId,
|
||||
IsReady = r.IsReady,
|
||||
Gates = r.Gates.Select(g => new GateResultResponse
|
||||
{
|
||||
GateName = g.GateName,
|
||||
Status = g.Status.ToString().ToLowerInvariant(),
|
||||
Message = g.Message,
|
||||
CheckedAt = g.CheckedAt,
|
||||
DurationMs = g.DurationMs
|
||||
}).ToList(),
|
||||
EvaluatedAt = r.EvaluatedAt
|
||||
};
|
||||
|
||||
private static PendingDeletionResponse MapDeletion(EnvModels.PendingDeletion d) => new()
|
||||
{
|
||||
PendingDeletionId = d.Id,
|
||||
EntityType = d.EntityType.ToString().ToLowerInvariant(),
|
||||
EntityName = d.EntityName,
|
||||
Status = d.Status.ToString().ToLowerInvariant(),
|
||||
CoolOffExpiresAt = d.CoolOffExpiresAt,
|
||||
CanConfirmAfter = d.CoolOffExpiresAt,
|
||||
CascadeSummary = new CascadeSummaryResponse
|
||||
{
|
||||
ChildEnvironments = d.CascadeSummary.ChildEnvironments,
|
||||
ChildTargets = d.CascadeSummary.ChildTargets,
|
||||
BoundAgents = d.CascadeSummary.BoundAgents,
|
||||
InfrastructureBindings = d.CascadeSummary.InfrastructureBindings,
|
||||
ActiveHealthSchedules = d.CascadeSummary.ActiveHealthSchedules,
|
||||
PendingDeployments = d.CascadeSummary.PendingDeployments
|
||||
},
|
||||
RequestedAt = d.RequestedAt,
|
||||
ConfirmedAt = d.ConfirmedAt,
|
||||
CompletedAt = d.CompletedAt
|
||||
};
|
||||
|
||||
// ── API Request/Response DTOs ────────────────────────────────
|
||||
|
||||
internal sealed class CreateRegionApiRequest
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? CryptoProfile { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class UpdateRegionApiRequest
|
||||
{
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? CryptoProfile { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
public EnvModels.RegionStatus? Status { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class BindInfrastructureApiRequest
|
||||
{
|
||||
public required Guid IntegrationId { get; set; }
|
||||
public required string ScopeType { get; set; }
|
||||
public Guid? ScopeId { get; set; }
|
||||
public required string BindingRole { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RenameApiRequest
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RequestDeleteApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RegionResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required string CryptoProfile { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RegionListResponse
|
||||
{
|
||||
public required List<RegionResponse> Items { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfrastructureBindingResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid IntegrationId { get; set; }
|
||||
public required string ScopeType { get; set; }
|
||||
public Guid? ScopeId { get; set; }
|
||||
public required string BindingRole { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfrastructureBindingListResponse
|
||||
{
|
||||
public required List<InfrastructureBindingResponse> Items { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfrastructureBindingResolutionResponse
|
||||
{
|
||||
public ResolvedBindingResponse? Registry { get; set; }
|
||||
public ResolvedBindingResponse? Vault { get; set; }
|
||||
public ResolvedBindingResponse? SettingsStore { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ResolvedBindingResponse
|
||||
{
|
||||
public required InfrastructureBindingResponse Binding { get; set; }
|
||||
public required string ResolvedFrom { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TopologyPointReportResponse
|
||||
{
|
||||
public Guid TargetId { get; set; }
|
||||
public Guid EnvironmentId { get; set; }
|
||||
public bool IsReady { get; set; }
|
||||
public required List<GateResultResponse> Gates { get; set; }
|
||||
public DateTimeOffset EvaluatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GateResultResponse
|
||||
{
|
||||
public required string GateName { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public DateTimeOffset? CheckedAt { get; set; }
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ReadinessListResponse
|
||||
{
|
||||
public required List<TopologyPointReportResponse> Items { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PendingDeletionResponse
|
||||
{
|
||||
public Guid PendingDeletionId { get; set; }
|
||||
public required string EntityType { get; set; }
|
||||
public required string EntityName { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public DateTimeOffset CoolOffExpiresAt { get; set; }
|
||||
public DateTimeOffset CanConfirmAfter { get; set; }
|
||||
public required CascadeSummaryResponse CascadeSummary { get; set; }
|
||||
public DateTimeOffset RequestedAt { get; set; }
|
||||
public DateTimeOffset? ConfirmedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class CascadeSummaryResponse
|
||||
{
|
||||
public int ChildEnvironments { get; set; }
|
||||
public int ChildTargets { get; set; }
|
||||
public int BoundAgents { get; set; }
|
||||
public int InfrastructureBindings { get; set; }
|
||||
public int ActiveHealthSchedules { get; set; }
|
||||
public int PendingDeployments { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PendingDeletionListResponse
|
||||
{
|
||||
public required List<PendingDeletionResponse> Items { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -260,10 +260,6 @@ public static class ConcelierOptionsValidator
|
||||
}
|
||||
}
|
||||
|
||||
if (mirror.Enabled && mirror.Domains.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror distribution requires at least one domain when enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAdvisoryChunks(ConcelierOptions.AdvisoryChunkOptions chunks)
|
||||
|
||||
@@ -572,6 +572,93 @@ builder.Services.Configure<MirrorConfigOptions>(builder.Configuration.GetSection
|
||||
builder.Services.AddHttpClient("MirrorTest");
|
||||
builder.Services.AddHttpClient("MirrorConsumer");
|
||||
|
||||
// ── Topology Setup Services (in-memory stores, future: DB-backed) ──
|
||||
{
|
||||
// Shared tenant/user ID provider for topology services
|
||||
Func<Guid> topologyTenantProvider = () => Guid.Empty; // Will be populated per-request by middleware
|
||||
Func<Guid> topologyUserProvider = () => Guid.Empty;
|
||||
|
||||
// Region
|
||||
builder.Services.AddSingleton(new StellaOps.ReleaseOrchestrator.Environment.Region.InMemoryRegionStore(topologyTenantProvider));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Region.IRegionStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Region.InMemoryRegionStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Region.IRegionService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Region.RegionService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Region.IRegionStore>(),
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Region.RegionService>(),
|
||||
topologyTenantProvider, topologyUserProvider));
|
||||
|
||||
// Environment (uses existing stores if available, or creates new)
|
||||
builder.Services.AddSingleton(new StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore(topologyTenantProvider));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Services.IEnvironmentService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService>(),
|
||||
topologyTenantProvider, topologyUserProvider));
|
||||
|
||||
// Target
|
||||
builder.Services.AddSingleton(new StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore(topologyTenantProvider));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester, StellaOps.ReleaseOrchestrator.Environment.Target.NoOpTargetConnectionTester>();
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetRegistry>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester>(),
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry>(),
|
||||
topologyTenantProvider));
|
||||
|
||||
// Infrastructure Binding
|
||||
builder.Services.AddSingleton(new StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.InMemoryInfrastructureBindingStore(topologyTenantProvider));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.IInfrastructureBindingStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.InMemoryInfrastructureBindingStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.IInfrastructureBindingService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.InfrastructureBindingService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.IInfrastructureBindingStore>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Services.IEnvironmentService>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.InfrastructureBindingService>(),
|
||||
TimeProvider.System, topologyTenantProvider, topologyUserProvider));
|
||||
|
||||
// Readiness
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Readiness.InMemoryTopologyPointStatusStore>();
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Readiness.ITopologyPointStatusStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Readiness.InMemoryTopologyPointStatusStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Readiness.ITopologyReadinessService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Readiness.TopologyReadinessService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetRegistry>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding.IInfrastructureBindingService>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Readiness.ITopologyPointStatusStore>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Readiness.TopologyReadinessService>(),
|
||||
TimeProvider.System, topologyTenantProvider));
|
||||
|
||||
// Rename
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Rename.ITopologyRenameService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Rename.TopologyRenameService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Region.IRegionService>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Services.IEnvironmentService>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetRegistry>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Region.IRegionStore>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Rename.TopologyRenameService>()));
|
||||
|
||||
// Deletion
|
||||
builder.Services.AddSingleton(new StellaOps.ReleaseOrchestrator.Environment.Deletion.InMemoryPendingDeletionStore(topologyTenantProvider));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Deletion.IPendingDeletionStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Deletion.InMemoryPendingDeletionStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Deletion.IPendingDeletionService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Deletion.PendingDeletionService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Deletion.IPendingDeletionStore>(),
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Deletion.PendingDeletionService>(),
|
||||
topologyTenantProvider, topologyUserProvider));
|
||||
builder.Services.AddHostedService<StellaOps.ReleaseOrchestrator.Environment.Deletion.DeletionBackgroundWorker>();
|
||||
}
|
||||
|
||||
// Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b)
|
||||
builder.Services.Configure<MirrorDistributionOptions>(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName));
|
||||
builder.Services.AddHostedService<MirrorExportScheduler>();
|
||||
@@ -850,11 +937,17 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView);
|
||||
options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
||||
options.AddStellaOpsAnyScopePolicy("Concelier.Sources.Manage", StellaOpsScopes.IntegrationWrite, StellaOpsScopes.IntegrationOperate);
|
||||
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
|
||||
options.AddStellaOpsScopePolicy(CanonicalReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
||||
options.AddStellaOpsScopePolicy(CanonicalIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
options.AddStellaOpsScopePolicy(InterestReadPolicyName, StellaOpsScopes.VulnView);
|
||||
options.AddStellaOpsScopePolicy(InterestAdminPolicyName, StellaOpsScopes.AdvisoryIngest);
|
||||
|
||||
// Topology setup policies (regions, infra bindings, readiness, rename, deletion)
|
||||
options.AddStellaOpsAnyScopePolicy("Topology.Read", StellaOpsScopes.OrchRead, StellaOpsScopes.PlatformContextRead);
|
||||
options.AddStellaOpsAnyScopePolicy("Topology.Manage", StellaOpsScopes.OrchOperate, StellaOpsScopes.IntegrationWrite);
|
||||
options.AddStellaOpsAnyScopePolicy("Topology.Admin", StellaOpsScopes.OrchOperate);
|
||||
});
|
||||
|
||||
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
|
||||
@@ -968,6 +1061,9 @@ app.MapFeedMirrorManagementEndpoints();
|
||||
// Mirror domain management CRUD endpoints
|
||||
app.MapMirrorDomainManagementEndpoints();
|
||||
|
||||
// Topology setup endpoints (regions, infrastructure bindings, readiness, rename, deletion)
|
||||
app.MapTopologySetupEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
{
|
||||
var (payload, etag) = provider.GetDocument();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
@@ -11,11 +12,29 @@ namespace StellaOps.Concelier.WebService.Services;
|
||||
public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfigStore, IMirrorConsumerConfigStore, IMirrorBundleImportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, MirrorDomainRecord> _domains = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _configLock = new();
|
||||
private readonly object _consumerConfigLock = new();
|
||||
private MirrorConfigRecord _config;
|
||||
private ConsumerConfigResponse _consumerConfig = new();
|
||||
private volatile MirrorImportStatusRecord? _latestImportStatus;
|
||||
|
||||
public IReadOnlyList<MirrorDomainRecord> GetAllDomains() => _domains.Values.ToList();
|
||||
public InMemoryMirrorDomainStore(IOptions<MirrorConfigOptions> options)
|
||||
{
|
||||
var current = options.Value;
|
||||
_config = new MirrorConfigRecord
|
||||
{
|
||||
Mode = NormalizeMode(current.Mode),
|
||||
OutputRoot = current.OutputRoot,
|
||||
ConsumerBaseAddress = current.ConsumerBaseAddress,
|
||||
SigningEnabled = current.SigningEnabled,
|
||||
SigningAlgorithm = current.SigningAlgorithm,
|
||||
SigningKeyId = current.SigningKeyId,
|
||||
AutoRefreshEnabled = current.AutoRefreshEnabled,
|
||||
RefreshIntervalMinutes = current.RefreshIntervalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
public IReadOnlyList<MirrorDomainRecord> GetAllDomains() => _domains.Values.OrderBy(domain => domain.DisplayName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
public MirrorDomainRecord? GetDomain(string domainId) => _domains.GetValueOrDefault(domainId);
|
||||
|
||||
@@ -31,8 +50,43 @@ public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfi
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public MirrorConfigRecord GetConfig()
|
||||
{
|
||||
lock (_configLock)
|
||||
{
|
||||
return new MirrorConfigRecord
|
||||
{
|
||||
Mode = _config.Mode,
|
||||
OutputRoot = _config.OutputRoot,
|
||||
ConsumerBaseAddress = _config.ConsumerBaseAddress,
|
||||
SigningEnabled = _config.SigningEnabled,
|
||||
SigningAlgorithm = _config.SigningAlgorithm,
|
||||
SigningKeyId = _config.SigningKeyId,
|
||||
AutoRefreshEnabled = _config.AutoRefreshEnabled,
|
||||
RefreshIntervalMinutes = _config.RefreshIntervalMinutes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateConfigAsync(UpdateMirrorConfigRequest request, CancellationToken ct = default)
|
||||
{
|
||||
lock (_configLock)
|
||||
{
|
||||
_config.Mode = NormalizeMode(request.Mode) ?? _config.Mode;
|
||||
_config.ConsumerBaseAddress = string.IsNullOrWhiteSpace(request.ConsumerBaseAddress)
|
||||
? _config.ConsumerBaseAddress
|
||||
: request.ConsumerBaseAddress.Trim();
|
||||
_config.SigningEnabled = request.Signing?.Enabled ?? _config.SigningEnabled;
|
||||
_config.SigningAlgorithm = string.IsNullOrWhiteSpace(request.Signing?.Algorithm)
|
||||
? _config.SigningAlgorithm
|
||||
: request.Signing!.Algorithm!.Trim();
|
||||
_config.SigningKeyId = string.IsNullOrWhiteSpace(request.Signing?.KeyId)
|
||||
? _config.SigningKeyId
|
||||
: request.Signing!.KeyId!.Trim();
|
||||
_config.AutoRefreshEnabled = request.AutoRefreshEnabled ?? _config.AutoRefreshEnabled;
|
||||
_config.RefreshIntervalMinutes = request.RefreshIntervalMinutes ?? _config.RefreshIntervalMinutes;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -64,10 +118,15 @@ public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfi
|
||||
}
|
||||
: null,
|
||||
Connected = !string.IsNullOrWhiteSpace(request.BaseAddress),
|
||||
LastSync = _consumerConfig.LastSync, // preserve existing sync timestamp
|
||||
LastSync = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
lock (_configLock)
|
||||
{
|
||||
_config.ConsumerBaseAddress = request.BaseAddress;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -76,4 +135,19 @@ public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfi
|
||||
public MirrorImportStatusRecord? GetLatestStatus() => _latestImportStatus;
|
||||
|
||||
public void SetStatus(MirrorImportStatusRecord status) => _latestImportStatus = status;
|
||||
|
||||
private static string NormalizeMode(string? mode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
return "Direct";
|
||||
}
|
||||
|
||||
return mode.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"mirror" => "Mirror",
|
||||
"hybrid" => "Hybrid",
|
||||
_ => "Direct",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
<ProjectReference Include="../../ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Environment/StellaOps.ReleaseOrchestrator.Environment.csproj" />
|
||||
<ProjectReference Include="../../ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Agent/StellaOps.ReleaseOrchestrator.Agent.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public sealed class ConcelierOptionsValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
[Fact]
|
||||
public void Validate_AllowsEnabledMirrorWithoutStaticDomains()
|
||||
{
|
||||
var options = new ConcelierOptions
|
||||
{
|
||||
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=postgres;Database=stellaops;Username=stellaops;Password=stellaops"
|
||||
},
|
||||
Mirror = new ConcelierOptions.MirrorOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ExportRoot = "/var/lib/concelier/jobs/mirror-exports",
|
||||
ExportRootAbsolute = "/var/lib/concelier/jobs/mirror-exports",
|
||||
LatestDirectoryName = "latest",
|
||||
MirrorDirectoryName = "mirror"
|
||||
},
|
||||
Evidence = new ConcelierOptions.EvidenceBundleOptions
|
||||
{
|
||||
Root = "/var/lib/concelier/jobs/evidence-bundles",
|
||||
RootAbsolute = "/var/lib/concelier/jobs/evidence-bundles"
|
||||
}
|
||||
};
|
||||
|
||||
var exception = Record.Exception(() => ConcelierOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
@@ -1532,6 +1532,81 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.True(limitedResponse.Headers.RetryAfter!.Delta!.Value.TotalSeconds > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MirrorManagementEndpointsUseAdvisorySourcesNamespaceAndGeneratePublicArtifacts()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var environment = new Dictionary<string, string?>
|
||||
{
|
||||
["CONCELIER_MIRROR__ENABLED"] = "true",
|
||||
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
|
||||
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = "latest",
|
||||
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "5",
|
||||
["CONCELIER_MIRROR__MAXDOWNLOADREQUESTSPERHOUR"] = "5"
|
||||
};
|
||||
|
||||
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "test-tenant");
|
||||
|
||||
var configResponse = await client.GetAsync("/api/v1/advisory-sources/mirror/config");
|
||||
Assert.Equal(HttpStatusCode.OK, configResponse.StatusCode);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/advisory-sources/mirror/domains",
|
||||
new
|
||||
{
|
||||
domainId = "primary",
|
||||
displayName = "Primary",
|
||||
sourceIds = new[] { "nvd", "osv" },
|
||||
exportFormat = "JSON",
|
||||
rateLimits = new
|
||||
{
|
||||
indexRequestsPerHour = 60,
|
||||
downloadRequestsPerHour = 120
|
||||
},
|
||||
requireAuthentication = false,
|
||||
signing = new
|
||||
{
|
||||
enabled = false,
|
||||
algorithm = "HMAC-SHA256",
|
||||
keyId = string.Empty
|
||||
}
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var generateResponse = await client.PostAsync("/api/v1/advisory-sources/mirror/domains/primary/generate", content: null);
|
||||
Assert.Equal(HttpStatusCode.Accepted, generateResponse.StatusCode);
|
||||
|
||||
var endpointsResponse = await client.GetAsync("/api/v1/advisory-sources/mirror/domains/primary/endpoints");
|
||||
Assert.Equal(HttpStatusCode.OK, endpointsResponse.StatusCode);
|
||||
var endpointsJson = JsonDocument.Parse(await endpointsResponse.Content.ReadAsStringAsync());
|
||||
var endpoints = endpointsJson.RootElement.GetProperty("endpoints").EnumerateArray().Select(element => element.GetProperty("path").GetString()).ToList();
|
||||
Assert.Contains("/concelier/exports/index.json", endpoints);
|
||||
Assert.Contains("/concelier/exports/mirror/primary/manifest.json", endpoints);
|
||||
Assert.Contains("/concelier/exports/mirror/primary/bundle.json", endpoints);
|
||||
|
||||
var statusResponse = await client.GetAsync("/api/v1/advisory-sources/mirror/domains/primary/status");
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
var statusJson = JsonDocument.Parse(await statusResponse.Content.ReadAsStringAsync());
|
||||
Assert.Equal("fresh", statusJson.RootElement.GetProperty("staleness").GetString());
|
||||
|
||||
var indexResponse = await client.GetAsync("/concelier/exports/index.json");
|
||||
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
|
||||
var indexContent = await indexResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains(@"""domainId"":""primary""", indexContent, StringComparison.Ordinal);
|
||||
|
||||
var manifestResponse = await client.GetAsync("/concelier/exports/mirror/primary/manifest.json");
|
||||
Assert.Equal(HttpStatusCode.OK, manifestResponse.StatusCode);
|
||||
var manifestContent = await manifestResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains(@"""domainId"":""primary""", manifestContent, StringComparison.Ordinal);
|
||||
|
||||
var bundleResponse = await client.GetAsync("/concelier/exports/mirror/primary/bundle.json");
|
||||
Assert.Equal(HttpStatusCode.OK, bundleResponse.StatusCode);
|
||||
var bundleContent = await bundleResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains(@"""domainId"":""primary""", bundleContent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeModuleDisabledByDefault()
|
||||
{
|
||||
@@ -4002,4 +4077,3 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user