Add mirror client setup wizard for consumer configuration

Backend: 4 consumer API endpoints (GET/PUT /consumer config, POST
/consumer/discover for index parsing, POST /consumer/verify-signature
for JWS header detection), air-gap bundle import endpoint with manifest
parsing and SHA256 verification, IMirrorConsumerConfigStore and
IMirrorBundleImportStore interfaces.

Frontend: 4-step mirror client setup wizard (connect + test, signature
verification with auto-detect, sync mode + schedule + air-gap import,
review + pre-flight checks + activate). Dashboard consumer panel with
"Configure" button, Direct mode "Switch to Mirror" CTA, catalog header
"Connect to Mirror" link and consumer status display.

E2E: 9 Playwright test scenarios covering wizard steps, connection
testing, domain discovery, signature detection, mode selection,
pre-flight checks, dashboard integration, and catalog integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-15 14:35:19 +02:00
parent ef4991cdd0
commit 9add6af221
10 changed files with 4034 additions and 22 deletions

View File

@@ -1,3 +1,5 @@
using System.Net.Http.Headers;
using System.Text.Json;
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -305,6 +307,251 @@ internal static class MirrorDomainManagementEndpointExtensions
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorReadPolicy);
// ===== Air-gap bundle import endpoints =====
// POST /import — import a mirror bundle from a local path
group.MapPost("/import", async ([FromBody] MirrorBundleImportRequest request, [FromServices] IMirrorBundleImportStore importStore, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BundlePath))
{
return HttpResults.BadRequest(new { error = "bundle_path_required" });
}
var resolvedPath = Path.GetFullPath(request.BundlePath);
if (!Directory.Exists(resolvedPath) && !File.Exists(resolvedPath))
{
return HttpResults.BadRequest(new { error = "bundle_path_not_found", path = resolvedPath });
}
// Start import asynchronously
var importId = Guid.NewGuid().ToString("N")[..12];
var status = new MirrorImportStatusRecord
{
ImportId = importId,
BundlePath = resolvedPath,
StartedAt = DateTimeOffset.UtcNow,
State = "running",
};
importStore.SetStatus(status);
// Run import in background (fire-and-forget with status tracking)
_ = Task.Run(async () =>
{
var warnings = new List<string>();
var errors = new List<string>();
var exportsImported = 0;
long totalSize = 0;
try
{
// 1. Locate manifest
string manifestPath;
string bundleDir;
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifestPath = resolvedPath;
bundleDir = Path.GetDirectoryName(resolvedPath)!;
}
else
{
bundleDir = resolvedPath;
var candidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
.ToArray();
if (candidates.Length == 0)
{
errors.Add("No manifest file found in bundle directory");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
importStore.SetStatus(status);
return;
}
manifestPath = candidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
}
// 2. Parse manifest
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
var manifest = System.Text.Json.JsonSerializer.Deserialize<MirrorBundleManifestDto>(
manifestJson,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (manifest is null)
{
errors.Add("Failed to parse bundle manifest JSON");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
importStore.SetStatus(status);
return;
}
status.DomainId = manifest.DomainId;
status.DisplayName = manifest.DisplayName;
importStore.SetStatus(status);
// 3. Verify checksums if requested
if (request.VerifyChecksums)
{
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
if (File.Exists(checksumPath))
{
var lines = await File.ReadAllLinesAsync(checksumPath, CancellationToken.None);
foreach (var line in lines.Where(l => !string.IsNullOrWhiteSpace(l)))
{
var parts = line.Split([' ', '\t'], 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var expected = parts[0].Trim();
var fileName = parts[1].Trim().TrimStart('*');
var filePath = Path.Combine(bundleDir, fileName);
if (!File.Exists(filePath))
{
warnings.Add($"Checksum file missing: {fileName}");
continue;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, CancellationToken.None);
var actual = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(fileBytes)).ToLowerInvariant()}";
var isValid = string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase) ||
string.Equals($"sha256:{expected}", actual, StringComparison.OrdinalIgnoreCase);
if (!isValid)
{
errors.Add($"Checksum mismatch for {fileName}: expected {expected}, got {actual}");
}
}
}
else
{
warnings.Add("SHA256SUMS file not found in bundle; checksum verification skipped");
}
}
// 4. Verify DSSE if requested
if (request.VerifyDsse)
{
var dsseFiles = Directory.GetFiles(bundleDir, "*.dsse.json")
.Concat(Directory.GetFiles(bundleDir, "*envelope.json"))
.ToArray();
if (dsseFiles.Length == 0)
{
warnings.Add("No DSSE envelope found in bundle; signature verification skipped");
}
else
{
// DSSE envelope found — note presence for audit
warnings.Add($"DSSE envelope found at {Path.GetFileName(dsseFiles[0])}; full cryptographic verification requires trust roots");
}
}
// 5. Copy artifacts to data store
var dataStorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"stellaops", "mirror-imports", manifest.DomainId ?? importId);
Directory.CreateDirectory(dataStorePath);
var allFiles = Directory.GetFiles(bundleDir);
foreach (var file in allFiles)
{
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
await using var sourceStream = File.OpenRead(file);
await using var destStream = File.Create(destPath);
await sourceStream.CopyToAsync(destStream, CancellationToken.None);
totalSize += new FileInfo(file).Length;
}
exportsImported = manifest.Exports?.Count ?? 0;
// Determine final state
if (errors.Count > 0)
{
status.State = "completed_with_errors";
}
else
{
status.State = "completed";
}
status.CompletedAt = DateTimeOffset.UtcNow;
status.Success = errors.Count == 0;
status.ExportsImported = exportsImported;
status.TotalSize = totalSize;
status.Errors = errors;
status.Warnings = warnings;
importStore.SetStatus(status);
}
catch (Exception ex)
{
errors.Add($"Import failed: {ex.Message}");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
status.Warnings = warnings;
importStore.SetStatus(status);
}
}, CancellationToken.None);
return HttpResults.Accepted($"/api/v1/mirror/import/status", new MirrorBundleImportAcceptedResponse
{
ImportId = importId,
Status = "running",
BundlePath = resolvedPath,
StartedAt = status.StartedAt,
});
})
.WithName("ImportMirrorBundle")
.WithSummary("Import a mirror bundle from a local filesystem path")
.WithDescription("Triggers an asynchronous import of a mirror bundle from the specified local path. The import verifies checksums and DSSE signatures as configured, then copies artifacts to the local data store. Poll the status endpoint for progress.")
.Produces<MirrorBundleImportAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// GET /import/status — get status of last import
group.MapGet("/import/status", ([FromServices] IMirrorBundleImportStore importStore) =>
{
var status = importStore.GetLatestStatus();
if (status is null)
{
return HttpResults.Ok(new MirrorBundleImportStatusResponse
{
HasImport = false,
Status = "none",
Message = "No import has been initiated",
});
}
return HttpResults.Ok(new MirrorBundleImportStatusResponse
{
HasImport = true,
ImportId = status.ImportId,
Status = status.State,
BundlePath = status.BundlePath,
DomainId = status.DomainId,
DisplayName = status.DisplayName,
StartedAt = status.StartedAt,
CompletedAt = status.CompletedAt,
Success = status.Success,
ExportsImported = status.ExportsImported,
TotalSize = status.TotalSize,
Errors = status.Errors,
Warnings = status.Warnings,
});
})
.WithName("GetMirrorImportStatus")
.WithSummary("Get status of the last mirror bundle import")
.WithDescription("Returns the current status, progress, and result of the most recent mirror bundle import operation.")
.Produces<MirrorBundleImportStatusResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// POST /test — test mirror consumer endpoint connectivity
group.MapPost("/test", async ([FromBody] MirrorTestRequest request, HttpContext httpContext, CancellationToken ct) =>
{
@@ -345,6 +592,235 @@ internal static class MirrorDomainManagementEndpointExtensions
.WithDescription("Sends a probe request to the specified mirror consumer base address and reports reachability, HTTP status code, and any connection errors.")
.Produces<MirrorTestResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorManagePolicy);
// ===== Consumer connector configuration endpoints =====
// GET /consumer — get current consumer connector configuration
group.MapGet("/consumer", ([FromServices] IMirrorConsumerConfigStore consumerStore) =>
{
var config = consumerStore.GetConsumerConfig();
return HttpResults.Ok(config);
})
.WithName("GetConsumerConfig")
.WithSummary("Get current consumer connector configuration")
.WithDescription("Returns the consumer connector configuration including base address, domain, signature settings, connection status, and last sync timestamp.")
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// PUT /consumer — update consumer connector configuration
group.MapPut("/consumer", async ([FromBody] ConsumerConfigRequest request, [FromServices] IMirrorConsumerConfigStore consumerStore, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
if (string.IsNullOrWhiteSpace(request.DomainId))
{
return HttpResults.BadRequest(new { error = "domain_id_required" });
}
await consumerStore.SetConsumerConfigAsync(request, ct);
var updated = consumerStore.GetConsumerConfig();
return HttpResults.Ok(updated);
})
.WithName("UpdateConsumerConfig")
.WithSummary("Update consumer connector configuration")
.WithDescription("Updates the consumer connector configuration including base address, domain, index path, HTTP timeout, and signature verification settings. The connector will use these settings for subsequent mirror sync operations.")
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// POST /consumer/discover — fetch mirror index and return available domains
group.MapPost("/consumer/discover", async ([FromBody] ConsumerDiscoverRequest request, HttpContext httpContext, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
var indexPath = string.IsNullOrWhiteSpace(request.IndexPath)
? "/concelier/exports/index.json"
: request.IndexPath.TrimStart('/');
var indexUrl = $"{request.BaseAddress.TrimEnd('/')}/{indexPath.TrimStart('/')}";
try
{
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient("MirrorConsumer");
client.Timeout = TimeSpan.FromSeconds(request.HttpTimeoutSeconds ?? 30);
var response = await client.GetAsync(indexUrl, ct);
if (!response.IsSuccessStatusCode)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Mirror returned HTTP {(int)response.StatusCode} from {indexUrl}",
});
}
var json = await response.Content.ReadAsStringAsync(ct);
var indexDoc = JsonSerializer.Deserialize<MirrorIndexDocument>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
if (indexDoc?.Domains is null || indexDoc.Domains.Count == 0)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = "Mirror index contains no domains",
});
}
var domains = indexDoc.Domains.Select(d => new MirrorDiscoveryDomain
{
DomainId = d.DomainId ?? d.Id ?? "",
DisplayName = d.DisplayName ?? d.DomainId ?? d.Id ?? "",
LastGenerated = d.LastGenerated,
AdvisoryCount = d.AdvisoryCount,
BundleSize = d.BundleSize,
ExportFormats = d.ExportFormats ?? [],
Signed = d.Signed,
}).ToList();
return HttpResults.Ok(new MirrorDiscoveryResponse { Domains = domains });
}
catch (TaskCanceledException)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Request to {indexUrl} timed out",
});
}
catch (HttpRequestException ex)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Failed to fetch mirror index: {ex.Message}",
});
}
})
.WithName("DiscoverMirrorDomains")
.WithSummary("Fetch mirror index and discover available domains")
.WithDescription("Fetches the mirror index document from the specified base address and returns the list of available domains with metadata including advisory counts, bundle sizes, export formats, and signature status.")
.Produces<MirrorDiscoveryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// POST /consumer/verify-signature — fetch bundle header and return signature details
group.MapPost("/consumer/verify-signature", async ([FromBody] ConsumerVerifySignatureRequest request, HttpContext httpContext, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
if (string.IsNullOrWhiteSpace(request.DomainId))
{
return HttpResults.BadRequest(new { error = "domain_id_required" });
}
var bundleUrl = $"{request.BaseAddress.TrimEnd('/')}/{request.DomainId}/bundle.json.jws";
try
{
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient("MirrorConsumer");
client.Timeout = TimeSpan.FromSeconds(15);
// Fetch only the initial portion to extract the JWS header
var httpRequest = new HttpRequestMessage(HttpMethod.Get, bundleUrl);
httpRequest.Headers.Range = new RangeHeaderValue(0, 4095);
var response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.PartialContent)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Bundle not found at {bundleUrl} (HTTP {(int)response.StatusCode})",
});
}
var content = await response.Content.ReadAsStringAsync(ct);
// JWS compact serialization: header.payload.signature
// Extract the header (first segment before the dot)
var firstDot = content.IndexOf('.');
if (firstDot <= 0)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = "Response does not appear to be a JWS (no header segment found)",
});
}
var headerB64 = content[..firstDot];
// Decode the JWS header (Base64url)
var padded = headerB64
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var headerJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
var header = JsonSerializer.Deserialize<JwsHeaderPayload>(headerJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = true,
Algorithm = header?.Alg,
KeyId = header?.Kid,
Provider = header?.Provider,
});
}
catch (TaskCanceledException)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Request to {bundleUrl} timed out",
});
}
catch (HttpRequestException ex)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Failed to fetch bundle header: {ex.Message}",
});
}
catch (FormatException)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = "JWS header could not be decoded (invalid Base64url)",
});
}
})
.WithName("VerifyMirrorSignature")
.WithSummary("Fetch bundle header and discover signature details")
.WithDescription("Fetches the JWS header from a domain's bundle at the specified mirror and extracts signature metadata including algorithm, key ID, and crypto provider. Only the header is downloaded, not the full bundle.")
.Produces<SignatureDiscoveryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
}
private static MirrorDomainSummary MapDomainSummary(MirrorDomainRecord domain) => new()
@@ -399,6 +875,25 @@ public interface IMirrorConfigStore
Task UpdateConfigAsync(UpdateMirrorConfigRequest request, CancellationToken ct = default);
}
/// <summary>
/// Store for mirror consumer connector configuration. Initial implementation: in-memory.
/// Future: DB-backed with migration.
/// </summary>
public interface IMirrorConsumerConfigStore
{
ConsumerConfigResponse GetConsumerConfig();
Task SetConsumerConfigAsync(ConsumerConfigRequest request, CancellationToken ct = default);
}
/// <summary>
/// Store for tracking mirror bundle import status. Initial implementation: in-memory.
/// </summary>
public interface IMirrorBundleImportStore
{
MirrorImportStatusRecord? GetLatestStatus();
void SetStatus(MirrorImportStatusRecord status);
}
// ===== Models =====
public sealed class MirrorDomainRecord
@@ -485,6 +980,36 @@ public sealed record MirrorTestRequest
public required string BaseAddress { get; init; }
}
public sealed record ConsumerConfigRequest
{
public required string BaseAddress { get; init; }
public required string DomainId { get; init; }
public string? IndexPath { get; init; }
public int? HttpTimeoutSeconds { get; init; }
public ConsumerSignatureRequest? Signature { get; init; }
}
public sealed record ConsumerSignatureRequest
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? PublicKeyPem { get; init; }
}
public sealed record ConsumerDiscoverRequest
{
public required string BaseAddress { get; init; }
public string? IndexPath { get; init; }
public int? HttpTimeoutSeconds { get; init; }
}
public sealed record ConsumerVerifySignatureRequest
{
public required string BaseAddress { get; init; }
public required string DomainId { get; init; }
}
// ===== Response DTOs =====
public sealed record MirrorConfigResponse
@@ -556,3 +1081,157 @@ public sealed record MirrorTestResponse
public int? StatusCode { get; init; }
public string? Message { get; init; }
}
// ===== Consumer connector DTOs =====
public sealed record ConsumerConfigResponse
{
public string? BaseAddress { get; init; }
public string? DomainId { get; init; }
public string IndexPath { get; init; } = "/concelier/exports/index.json";
public int HttpTimeoutSeconds { get; init; } = 30;
public ConsumerSignatureResponse? Signature { get; init; }
public bool Connected { get; init; }
public DateTimeOffset? LastSync { get; init; }
}
public sealed record ConsumerSignatureResponse
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? PublicKeyPem { get; init; }
}
public sealed record MirrorDiscoveryResponse
{
public IReadOnlyList<MirrorDiscoveryDomain> Domains { get; init; } = [];
public string? Error { get; init; }
}
public sealed record MirrorDiscoveryDomain
{
public string DomainId { get; init; } = "";
public string DisplayName { get; init; } = "";
public DateTimeOffset? LastGenerated { get; init; }
public long AdvisoryCount { get; init; }
public long BundleSize { get; init; }
public IReadOnlyList<string> ExportFormats { get; init; } = [];
public bool Signed { get; init; }
}
public sealed record SignatureDiscoveryResponse
{
public bool Detected { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? Provider { get; init; }
public string? Error { get; init; }
}
// ===== Bundle import DTOs =====
public sealed record MirrorBundleImportRequest
{
public required string BundlePath { get; init; }
public bool VerifyChecksums { get; init; } = true;
public bool VerifyDsse { get; init; } = true;
public string? TrustRootsPath { get; init; }
}
public sealed record MirrorBundleImportAcceptedResponse
{
public string ImportId { get; init; } = "";
public string Status { get; init; } = "running";
public string BundlePath { get; init; } = "";
public DateTimeOffset StartedAt { get; init; }
}
public sealed record MirrorBundleImportStatusResponse
{
public bool HasImport { get; init; }
public string? ImportId { get; init; }
public string Status { get; init; } = "none";
public string? Message { get; init; }
public string? BundlePath { get; init; }
public string? DomainId { get; init; }
public string? DisplayName { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public bool Success { get; init; }
public int ExportsImported { get; init; }
public long TotalSize { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// In-memory record for tracking an active or completed import operation.
/// </summary>
public sealed class MirrorImportStatusRecord
{
public string ImportId { get; set; } = "";
public string BundlePath { get; set; } = "";
public string State { get; set; } = "pending";
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public bool Success { get; set; }
public int ExportsImported { get; set; }
public long TotalSize { get; set; }
public IReadOnlyList<string> Errors { get; set; } = [];
public IReadOnlyList<string> Warnings { get; set; } = [];
}
/// <summary>
/// Simplified manifest DTO for parsing bundle manifests during import.
/// </summary>
internal sealed class MirrorBundleManifestDto
{
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public DateTimeOffset GeneratedAt { get; set; }
public List<MirrorBundleExportDto>? Exports { get; set; }
}
internal sealed class MirrorBundleExportDto
{
public string? Key { get; set; }
public string? ExportId { get; set; }
public string? Format { get; set; }
public long? ArtifactSizeBytes { get; set; }
public string? ArtifactDigest { get; set; }
}
// ===== Internal deserialization models =====
/// <summary>
/// Represents the mirror index JSON document fetched during discovery.
/// </summary>
internal sealed class MirrorIndexDocument
{
public List<MirrorIndexDomainEntry> Domains { get; set; } = [];
}
internal sealed class MirrorIndexDomainEntry
{
public string? Id { get; set; }
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public DateTimeOffset? LastGenerated { get; set; }
public long AdvisoryCount { get; set; }
public long BundleSize { get; set; }
public List<string>? ExportFormats { get; set; }
public bool Signed { get; set; }
}
/// <summary>
/// Minimal JWS header fields used for signature discovery.
/// </summary>
internal sealed class JwsHeaderPayload
{
public string? Alg { get; set; }
public string? Kid { get; set; }
public string? Provider { get; set; }
}

View File

@@ -566,8 +566,11 @@ builder.Services.AddSourcesRegistry(builder.Configuration);
builder.Services.AddSingleton<InMemoryMirrorDomainStore>();
builder.Services.AddSingleton<IMirrorDomainStore>(sp => sp.GetRequiredService<InMemoryMirrorDomainStore>());
builder.Services.AddSingleton<IMirrorConfigStore>(sp => sp.GetRequiredService<InMemoryMirrorDomainStore>());
builder.Services.AddSingleton<IMirrorConsumerConfigStore>(sp => sp.GetRequiredService<InMemoryMirrorDomainStore>());
builder.Services.AddSingleton<IMirrorBundleImportStore>(sp => sp.GetRequiredService<InMemoryMirrorDomainStore>());
builder.Services.Configure<MirrorConfigOptions>(builder.Configuration.GetSection("Mirror"));
builder.Services.AddHttpClient("MirrorTest");
builder.Services.AddHttpClient("MirrorConsumer");
// Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b)
builder.Services.Configure<MirrorDistributionOptions>(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName));

View File

@@ -4,12 +4,16 @@ using StellaOps.Concelier.WebService.Extensions;
namespace StellaOps.Concelier.WebService.Services;
/// <summary>
/// In-memory implementation of <see cref="IMirrorDomainStore"/> and <see cref="IMirrorConfigStore"/>.
/// In-memory implementation of <see cref="IMirrorDomainStore"/>, <see cref="IMirrorConfigStore"/>,
/// <see cref="IMirrorConsumerConfigStore"/>, and <see cref="IMirrorBundleImportStore"/>.
/// Suitable for development and single-instance deployments. Future: replace with DB-backed store.
/// </summary>
public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfigStore
public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfigStore, IMirrorConsumerConfigStore, IMirrorBundleImportStore
{
private readonly ConcurrentDictionary<string, MirrorDomainRecord> _domains = new(StringComparer.OrdinalIgnoreCase);
private readonly object _consumerConfigLock = new();
private ConsumerConfigResponse _consumerConfig = new();
private volatile MirrorImportStatusRecord? _latestImportStatus;
public IReadOnlyList<MirrorDomainRecord> GetAllDomains() => _domains.Values.ToList();
@@ -31,4 +35,45 @@ public sealed class InMemoryMirrorDomainStore : IMirrorDomainStore, IMirrorConfi
{
return Task.CompletedTask;
}
public ConsumerConfigResponse GetConsumerConfig()
{
lock (_consumerConfigLock)
{
return _consumerConfig;
}
}
public Task SetConsumerConfigAsync(ConsumerConfigRequest request, CancellationToken ct = default)
{
lock (_consumerConfigLock)
{
_consumerConfig = new ConsumerConfigResponse
{
BaseAddress = request.BaseAddress,
DomainId = request.DomainId,
IndexPath = request.IndexPath ?? "/concelier/exports/index.json",
HttpTimeoutSeconds = request.HttpTimeoutSeconds ?? 30,
Signature = request.Signature is not null
? new ConsumerSignatureResponse
{
Enabled = request.Signature.Enabled,
Algorithm = request.Signature.Algorithm,
KeyId = request.Signature.KeyId,
PublicKeyPem = request.Signature.PublicKeyPem,
}
: null,
Connected = !string.IsNullOrWhiteSpace(request.BaseAddress),
LastSync = _consumerConfig.LastSync, // preserve existing sync timestamp
};
}
return Task.CompletedTask;
}
// ── IMirrorBundleImportStore ──────────────────────────────────────────────
public MirrorImportStatusRecord? GetLatestStatus() => _latestImportStatus;
public void SetStatus(MirrorImportStatusRecord status) => _latestImportStatus = status;
}