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:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user