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:
master
2026-03-16 08:12:39 +02:00
parent 602df77467
commit da76d6e93e
223 changed files with 24763 additions and 489 deletions

View File

@@ -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 =====

View File

@@ -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);

View File

@@ -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();

View File

@@ -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; }
}
}

View File

@@ -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)

View File

@@ -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();

View File

@@ -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",
};
}
}

View File

@@ -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" />

View File

@@ -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);
}
}

View File

@@ -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
}