work
This commit is contained in:
@@ -6,3 +6,4 @@
|
||||
| `CLI-AIAI-31-001` | DONE (2025-11-24) | `stella advise summarize` command implemented; CLI analyzer build & tests now pass locally. |
|
||||
| `CLI-AIAI-31-002` | DONE (2025-11-24) | `stella advise explain` (conflict narrative) command implemented and tested. |
|
||||
| `CLI-AIAI-31-003` | DONE (2025-11-24) | `stella advise remediate` command implemented and tested. |
|
||||
| `CLI-AIAI-31-004` | DONE (2025-11-24) | `stella advise batch` supports multi-key runs, per-key outputs, summary table, and tests (`HandleAdviseBatchAsync_RunsAllAdvisories`). |
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, deterministic writer for Concelier air-gap bundles. Intended as the
|
||||
/// first increment for CONCELIER-AIRGAP-56-001; produces a stable NDJSON file
|
||||
/// from link-not-merge cache items without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class AirgapBundleBuilder
|
||||
{
|
||||
private const string BundleFileName = "concelier-airgap.ndjson";
|
||||
private const string ManifestFileName = "bundle.manifest.json";
|
||||
private const string EntryTraceFileName = "bundle.entry-trace.json";
|
||||
|
||||
public async Task<AirgapBundleResult> BuildAsync(
|
||||
IEnumerable<string> cacheItems,
|
||||
string outputDirectory,
|
||||
DateTimeOffset? createdUtc = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (cacheItems is null) throw new ArgumentNullException(nameof(cacheItems));
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory)) throw new ArgumentException("Output directory is required", nameof(outputDirectory));
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var ordered = cacheItems
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var bundlePath = Path.Combine(outputDirectory, BundleFileName);
|
||||
await WriteNdjsonAsync(bundlePath, ordered, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleSha = ComputeSha256FromPath(bundlePath);
|
||||
|
||||
var entries = ordered
|
||||
.Select((value, index) => new AirgapBundleEntry
|
||||
{
|
||||
LineNumber = index + 1,
|
||||
Sha256 = ComputeSha256(value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var manifestCreated = createdUtc ?? DateTimeOffset.UnixEpoch;
|
||||
var manifest = new AirgapBundleManifest
|
||||
{
|
||||
Items = ordered,
|
||||
Entries = entries,
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedUtc = manifestCreated,
|
||||
Count = ordered.Length
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(outputDirectory, ManifestFileName);
|
||||
await WriteManifest(manifestPath, manifest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entryTracePath = Path.Combine(outputDirectory, EntryTraceFileName);
|
||||
await WriteEntryTrace(entryTracePath, entries, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AirgapBundleResult(bundlePath, manifestPath, entryTracePath, bundleSha, ordered.Length);
|
||||
}
|
||||
|
||||
private static async Task WriteNdjsonAsync(string bundlePath, IReadOnlyList<string> orderedItems, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
foreach (var item in orderedItems)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteLineAsync(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteManifest(string manifestPath, AirgapBundleManifest manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteEntryTrace(string entryTracePath, IReadOnlyList<AirgapBundleEntry> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(entryTracePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromPath(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleResult(string BundlePath, string ManifestPath, string EntryTracePath, string Sha256, int ItemCount);
|
||||
|
||||
public sealed record AirgapBundleManifest
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public string[] Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public AirgapBundleEntry[] Entries { get; init; } = Array.Empty<AirgapBundleEntry>();
|
||||
|
||||
[JsonPropertyName("bundleSha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdUtc")]
|
||||
public DateTimeOffset CreatedUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleEntry
|
||||
{
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int LineNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.AirGap;
|
||||
|
||||
public sealed class AirgapBundleValidator
|
||||
{
|
||||
public async Task<AirgapBundleValidationResult> ValidateAsync(
|
||||
string bundlePath,
|
||||
string manifestPath,
|
||||
string? entryTracePath = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
errors.Add($"Bundle file missing: {bundlePath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add($"Manifest file missing: {manifestPath}");
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
AirgapBundleManifest? manifest = null;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Manifest parse error: {ex.Message}");
|
||||
}
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
var bundleSha = ComputeSha256FromFile(bundlePath);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return new AirgapBundleValidationResult(false, errors);
|
||||
}
|
||||
|
||||
if (!string.Equals(bundleSha, manifest.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add("Bundle hash mismatch");
|
||||
}
|
||||
|
||||
if (manifest.Count != lines.Length)
|
||||
{
|
||||
errors.Add($"Manifest count {manifest.Count} != bundle lines {lines.Length}");
|
||||
}
|
||||
|
||||
var ordered = lines.ToArray();
|
||||
if (!manifest.Items.SequenceEqual(ordered))
|
||||
{
|
||||
errors.Add("Manifest items differ from bundle payload");
|
||||
}
|
||||
|
||||
// If entry trace exists (either provided or embedded in manifest), verify per-line hashes.
|
||||
AirgapBundleEntry[] entries = manifest.Entries ?? Array.Empty<AirgapBundleEntry>();
|
||||
if (!string.IsNullOrWhiteSpace(entryTracePath) && File.Exists(entryTracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var traceJson = await File.ReadAllTextAsync(entryTracePath!, cancellationToken).ConfigureAwait(false);
|
||||
var traceEntries = JsonSerializer.Deserialize<AirgapBundleEntry[]>(traceJson);
|
||||
if (traceEntries is not null)
|
||||
{
|
||||
entries = traceEntries;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Entry trace parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
if (entries.Length != lines.Length)
|
||||
{
|
||||
errors.Add($"Entry trace length {entries.Length} != bundle lines {lines.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var expectedHash = ComputeSha256(lines[i]);
|
||||
if (!string.Equals(entries[i].Sha256, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"Entry trace hash mismatch at line {i + 1}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AirgapBundleValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hashBytes = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromFile(string path)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirgapBundleValidationResult(bool IsValid, IReadOnlyList<string> Errors);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record VerifyAttestationRequest(
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? PipelineVersion);
|
||||
|
||||
public readonly record struct EvidencePathResolutionResult(
|
||||
bool IsValid,
|
||||
string? BundlePath,
|
||||
string? ManifestPath,
|
||||
string? TransparencyPath,
|
||||
string? Error,
|
||||
string? ErrorDetails)
|
||||
{
|
||||
public static EvidencePathResolutionResult Valid(string bundlePath, string manifestPath, string? transparencyPath) =>
|
||||
new(true, bundlePath, manifestPath, transparencyPath, null, null);
|
||||
|
||||
public static EvidencePathResolutionResult Invalid(string error, string? details = null) =>
|
||||
new(false, null, null, null, error, details);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record EvidenceBatchRequest(
|
||||
IReadOnlyCollection<EvidenceBatchItemRequest> Items,
|
||||
int? ObservationLimit,
|
||||
int? LinksetLimit);
|
||||
|
||||
public sealed record EvidenceBatchItemRequest(
|
||||
string? ComponentId,
|
||||
IReadOnlyCollection<string>? Purls,
|
||||
IReadOnlyCollection<string>? Aliases);
|
||||
|
||||
public sealed record EvidenceBatchItemResponse(
|
||||
string ComponentId,
|
||||
IReadOnlyCollection<AdvisoryObservation> Observations,
|
||||
IReadOnlyCollection<AdvisoryLinkset> Linksets,
|
||||
bool HasMore,
|
||||
DateTimeOffset RetrievedAt);
|
||||
|
||||
public sealed record EvidenceBatchResponse(
|
||||
IReadOnlyCollection<EvidenceBatchItemResponse> Items);
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record EvidenceSnapshotResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("manifestHash")] string ManifestHash,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
|
||||
public sealed record AttestationStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("claims")] AttestationClaims Claims,
|
||||
[property: JsonPropertyName("bundlePath")] string BundlePath,
|
||||
[property: JsonPropertyName("manifestPath")] string ManifestPath,
|
||||
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService;
|
||||
|
||||
public sealed record IncidentUpsertRequest(
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("cooldownMinutes")] int? CooldownMinutes);
|
||||
|
||||
public sealed record IncidentStatusResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("activatedAt")] string ActivatedAt,
|
||||
[property: JsonPropertyName("cooldownUntil")] string CooldownUntil,
|
||||
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion,
|
||||
[property: JsonPropertyName("active")] bool Active);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal static class IncidentFileStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static string GetIncidentFilePath(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceOptions);
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
ArgumentNullException.ThrowIfNull(advisoryKey);
|
||||
|
||||
var root = evidenceOptions.RootAbsolute ?? evidenceOptions.Root ?? string.Empty;
|
||||
return Path.Combine(root, tenant.Trim(), advisoryKey.Trim(), "incident.json");
|
||||
}
|
||||
|
||||
public static async Task WriteAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
string reason,
|
||||
int cooldownMinutes,
|
||||
string? pipelineVersion,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var activatedAt = now.ToUniversalTime();
|
||||
var cooldownUntil = activatedAt.AddMinutes(cooldownMinutes);
|
||||
|
||||
var payload = new IncidentFile
|
||||
{
|
||||
AdvisoryKey = advisoryKey.Trim(),
|
||||
Tenant = tenant.Trim(),
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
|
||||
ActivatedAt = activatedAt,
|
||||
CooldownUntil = cooldownUntil,
|
||||
PipelineVersion = pipelineVersion,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<IncidentStatusResponse?> ReadAsync(
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var payload = await JsonSerializer.DeserializeAsync<IncidentFile>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var active = payload.CooldownUntil > now.ToUniversalTime();
|
||||
return new IncidentStatusResponse(
|
||||
payload.AdvisoryKey,
|
||||
payload.Tenant,
|
||||
payload.Reason,
|
||||
payload.ActivatedAt.ToUniversalTime().ToString("O"),
|
||||
payload.CooldownUntil.ToUniversalTime().ToString("O"),
|
||||
payload.PipelineVersion,
|
||||
active);
|
||||
}
|
||||
|
||||
public static Task DeleteAsync(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record IncidentFile
|
||||
{
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = "unspecified";
|
||||
public DateTimeOffset ActivatedAt { get; init; }
|
||||
public DateTimeOffset CooldownUntil { get; init; }
|
||||
public string? PipelineVersion { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Normalization.SemVer;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
|
||||
internal static class CccsMapper
|
||||
{
|
||||
@@ -108,44 +110,149 @@ internal static class CccsMapper
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Products.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>(dto.Products.Count);
|
||||
foreach (var product in dto.Products)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = product.Trim();
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"package",
|
||||
identifier,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
platform: null,
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
|
||||
}
|
||||
|
||||
return packages.Count == 0
|
||||
? Array.Empty<AffectedPackage>()
|
||||
: packages
|
||||
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Products.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>(dto.Products.Count);
|
||||
for (var index = 0; index < dto.Products.Count; index++)
|
||||
{
|
||||
var product = dto.Products[index];
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = product.Trim();
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"package",
|
||||
identifier,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
var rangeAnchor = $"cccs:{dto.SerialNumber}:{index}";
|
||||
var versionRanges = BuildVersionRanges(product, rangeAnchor, recordedAt);
|
||||
var normalizedVersions = BuildNormalizedVersions(versionRanges, rangeAnchor);
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
platform: null,
|
||||
versionRanges: versionRanges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: normalizedVersions));
|
||||
}
|
||||
|
||||
return packages.Count == 0
|
||||
? Array.Empty<AffectedPackage>()
|
||||
: packages
|
||||
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string productText, string rangeAnchor, DateTimeOffset recordedAt)
|
||||
{
|
||||
var versionText = ExtractFirstVersionToken(productText);
|
||||
if (string.IsNullOrWhiteSpace(versionText))
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var provenance = new AdvisoryProvenance(
|
||||
CccsConnectorPlugin.SourceName,
|
||||
"range",
|
||||
rangeAnchor,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
|
||||
var vendorExtensions = new Dictionary<string, string>
|
||||
{
|
||||
["cccs.version.raw"] = versionText!,
|
||||
["cccs.anchor"] = rangeAnchor,
|
||||
};
|
||||
|
||||
var semVerResults = SemVerRangeRuleBuilder.Build(versionText!, patchedVersion: null, provenanceNote: rangeAnchor);
|
||||
if (semVerResults.Count > 0)
|
||||
{
|
||||
return semVerResults.Select(result =>
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: result.Primitive.Introduced,
|
||||
fixedVersion: result.Primitive.Fixed,
|
||||
lastAffectedVersion: result.Primitive.LastAffected,
|
||||
rangeExpression: result.Expression ?? versionText!,
|
||||
provenance: provenance,
|
||||
primitives: new RangePrimitives(
|
||||
result.Primitive,
|
||||
Nevra: null,
|
||||
Evr: null,
|
||||
VendorExtensions: vendorExtensions)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var primitives = new RangePrimitives(
|
||||
new SemVerPrimitive(
|
||||
Introduced: versionText,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: null,
|
||||
FixedInclusive: false,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: true,
|
||||
ConstraintExpression: null,
|
||||
ExactValue: versionText),
|
||||
Nevra: null,
|
||||
Evr: null,
|
||||
VendorExtensions: vendorExtensions);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: versionText,
|
||||
provenance: provenance,
|
||||
primitives: primitives),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
IReadOnlyList<AffectedVersionRange> ranges,
|
||||
string rangeAnchor)
|
||||
{
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var rules = new List<NormalizedVersionRule>(ranges.Count);
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var rule = range.ToNormalizedVersionRule(rangeAnchor);
|
||||
if (rule is not null)
|
||||
{
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
|
||||
}
|
||||
|
||||
private static string? ExtractFirstVersionToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = Regex.Match(value, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?");
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Normalization.SemVer;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertBund.Internal;
|
||||
|
||||
@@ -116,23 +118,9 @@ internal static class CertBundMapper
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.AffectedPackages });
|
||||
|
||||
var ranges = string.IsNullOrWhiteSpace(product.Versions)
|
||||
? Array.Empty<AffectedVersionRange>()
|
||||
: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "string",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: product.Versions,
|
||||
provenance: new AdvisoryProvenance(
|
||||
CertBundConnectorPlugin.SourceName,
|
||||
"package-range",
|
||||
product.Versions,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges }))
|
||||
};
|
||||
var anchor = $"certbund:{dto.AdvisoryId}:{vendor.ToLowerInvariant().Replace(' ', '-')}";
|
||||
var ranges = BuildVersionRanges(product.Versions, anchor, recordedAt);
|
||||
var normalized = BuildNormalizedVersions(ranges, anchor);
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
@@ -141,7 +129,7 @@ internal static class CertBundMapper
|
||||
versionRanges: ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { provenance },
|
||||
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
|
||||
normalizedVersions: normalized));
|
||||
}
|
||||
|
||||
return packages
|
||||
@@ -150,6 +138,87 @@ internal static class CertBundMapper
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string? versions, string anchor, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(versions)
|
||||
|| string.Equals(versions.Trim(), "alle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var tokens = Regex.Matches(versions, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?")
|
||||
.Select(match => match.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var introduced = tokens.First();
|
||||
var fixedVersion = tokens.Count > 1 ? tokens.Last() : null;
|
||||
|
||||
var vendorExtensions = new Dictionary<string, string>
|
||||
{
|
||||
["certbund.version.raw"] = versions!,
|
||||
["certbund.anchor"] = anchor,
|
||||
};
|
||||
|
||||
var semVer = new SemVerPrimitive(
|
||||
Introduced: introduced,
|
||||
IntroducedInclusive: true,
|
||||
Fixed: fixedVersion,
|
||||
FixedInclusive: true,
|
||||
LastAffected: null,
|
||||
LastAffectedInclusive: true,
|
||||
ConstraintExpression: null,
|
||||
ExactValue: tokens.Count == 1 ? introduced : null);
|
||||
|
||||
var rangeProvenance = new AdvisoryProvenance(
|
||||
CertBundConnectorPlugin.SourceName,
|
||||
"package-range",
|
||||
anchor,
|
||||
recordedAt,
|
||||
new[] { ProvenanceFieldMasks.VersionRanges });
|
||||
|
||||
var primitives = new RangePrimitives(semVer, Nevra: null, Evr: null, VendorExtensions: vendorExtensions);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: NormalizedVersionSchemes.SemVer,
|
||||
introducedVersion: introduced,
|
||||
fixedVersion: fixedVersion,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: versions!,
|
||||
provenance: rangeProvenance,
|
||||
primitives: primitives),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
IReadOnlyList<AffectedVersionRange> ranges,
|
||||
string anchor)
|
||||
{
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var rules = new List<NormalizedVersionRule>(ranges.Count);
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var rule = range.ToNormalizedVersionRule(anchor);
|
||||
if (rule is not null)
|
||||
{
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
|
||||
}
|
||||
|
||||
private static string? MapSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Connector.Cccs.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
|
||||
|
||||
@@ -35,9 +36,17 @@ public sealed class CccsMapperTests
|
||||
advisory.AdvisoryKey.Should().Be("TEST-001");
|
||||
advisory.Title.Should().Be(dto.Title);
|
||||
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
|
||||
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
|
||||
}
|
||||
}
|
||||
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
|
||||
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
|
||||
|
||||
var first = advisory.AffectedPackages[0];
|
||||
first.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "1.0");
|
||||
first.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:0" && rule.Value == "1.0");
|
||||
|
||||
var second = advisory.AffectedPackages[1];
|
||||
second.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "2.0");
|
||||
second.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:1" && rule.Value == "2.0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.CertBund.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
@@ -57,16 +58,27 @@ public sealed class CertBundConnectorTests : IAsyncLifetime
|
||||
advisories.Should().HaveCount(1);
|
||||
|
||||
var advisory = advisories[0];
|
||||
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-1234");
|
||||
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
|
||||
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
|
||||
advisory.Language.Should().Be("de");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
|
||||
advisory.Aliases.Should().Contain("CVE-2025-1234");
|
||||
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
|
||||
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
|
||||
advisory.Language.Should().Be("de");
|
||||
|
||||
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
|
||||
.Subject;
|
||||
endpoint.VersionRanges.Should().ContainSingle(range =>
|
||||
range.RangeKind == NormalizedVersionSchemes.SemVer &&
|
||||
range.IntroducedVersion == "2023.1" &&
|
||||
range.FixedVersion == "2024.2");
|
||||
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
|
||||
rule.Min == "2023.1" &&
|
||||
rule.Max == "2024.2" &&
|
||||
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
|
||||
state.Should().NotBeNull();
|
||||
state!.Cursor.Should().NotBeNull();
|
||||
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
|
||||
pendingDocs!.AsBsonArray.Should().BeEmpty();
|
||||
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_ProducesClaimsFromSampleBundle()
|
||||
{
|
||||
var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle");
|
||||
@@ -22,7 +23,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
tarPath,
|
||||
manifestPath,
|
||||
transparencyPath,
|
||||
pipelineVersion: "git:test-sha"),
|
||||
"git:test-sha"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("evidence-bundle-m0", claims.SubjectName);
|
||||
@@ -38,6 +39,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_EnforcesLowercaseTenant()
|
||||
{
|
||||
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
|
||||
@@ -64,4 +66,30 @@ public sealed class EvidenceBundleAttestationBuilderTests
|
||||
|
||||
Assert.Contains("Tenant must be lowercase", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task BuildAsync_RequiresTenant()
|
||||
{
|
||||
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
|
||||
var manifest = """
|
||||
{
|
||||
"bundle_id": "test-bundle",
|
||||
"version": "1.0.0",
|
||||
"created": "2025-11-19T00:00:00Z",
|
||||
"scope": "vex"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(tempManifest, manifest);
|
||||
|
||||
var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz");
|
||||
await File.WriteAllTextAsync(tempTar, "dummy");
|
||||
|
||||
var builder = new EvidenceBundleAttestationBuilder();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None));
|
||||
|
||||
Assert.Contains("Tenant must be present", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Attestation;
|
||||
|
||||
public class EvidenceBundleAttestationValidator
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_RejectsMissingTenant()
|
||||
{
|
||||
var bundle = Path.GetTempFileName();
|
||||
var manifest = Path.GetTempFileName();
|
||||
await File.WriteAllTextAsync(bundle, "dummy");
|
||||
await File.WriteAllTextAsync(manifest, "{\"tenant\":\"ACME\"}");
|
||||
|
||||
var builder = new EvidenceBundleAttestationBuilder();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
builder.BuildAsync(new EvidenceBundleAttestationRequest(bundle, manifest, null, "git:test")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.WebService.AirGap;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.AirGap;
|
||||
|
||||
public class AirgapBundleBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_WritesDeterministicNdjson()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var created = DateTimeOffset.Parse("2025-11-01T00:00:00Z");
|
||||
var items = new[]
|
||||
{
|
||||
"b:2",
|
||||
"a:1",
|
||||
"c:3",
|
||||
"a:1" // duplicate should still appear twice to preserve raw cache content
|
||||
};
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-test");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName, created);
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(result.BundlePath);
|
||||
|
||||
Assert.Equal(4, lines.Length);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, lines);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
|
||||
Assert.Equal(4, result.ItemCount);
|
||||
Assert.True(File.Exists(result.ManifestPath));
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(result.ManifestPath);
|
||||
var manifest = System.Text.Json.JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson)!;
|
||||
Assert.Equal(result.Sha256, manifest.BundleSha256);
|
||||
Assert.Equal(4, manifest.Count);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, manifest.Items);
|
||||
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }.Select(v => v.GetDeterministicHash()), manifest.Entries.Select(e => e.Sha256));
|
||||
Assert.Equal(created, manifest.CreatedUtc);
|
||||
|
||||
var manifestJsonFirstRun = manifestJson;
|
||||
var entryTraceJsonFirstRun = await File.ReadAllTextAsync(result.EntryTracePath);
|
||||
|
||||
// Second run should produce identical hash
|
||||
var result2 = await builder.BuildAsync(items, tempDir.FullName, created);
|
||||
Assert.Equal(result.Sha256, result2.Sha256);
|
||||
Assert.Equal(result.ManifestPath, result2.ManifestPath); // paths stable in same directory
|
||||
|
||||
var manifestJsonSecondRun = await File.ReadAllTextAsync(result2.ManifestPath);
|
||||
var entryTraceJsonSecondRun = await File.ReadAllTextAsync(result2.EntryTracePath);
|
||||
Assert.Equal(manifestJsonFirstRun, manifestJsonSecondRun);
|
||||
Assert.Equal(entryTraceJsonFirstRun, entryTraceJsonSecondRun);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HashTestExtensions
|
||||
{
|
||||
public static string GetDeterministicHash(this string content)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
return System.Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.WebService.AirGap;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.AirGap;
|
||||
|
||||
public class AirgapBundleValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Succeeds_ForBuilderOutput()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var validator = new AirgapBundleValidator();
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator");
|
||||
|
||||
try
|
||||
{
|
||||
var items = new[] { "b:2", "a:1" };
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName);
|
||||
|
||||
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
|
||||
|
||||
Assert.True(validation.IsValid, string.Join(";", validation.Errors));
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Fails_WhenManifestTampered()
|
||||
{
|
||||
var builder = new AirgapBundleBuilder();
|
||||
var validator = new AirgapBundleValidator();
|
||||
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator-bad");
|
||||
|
||||
try
|
||||
{
|
||||
var items = new[] { "b:2", "a:1" };
|
||||
var result = await builder.BuildAsync(items, tempDir.FullName);
|
||||
|
||||
// Tamper manifest count
|
||||
var manifest = await File.ReadAllTextAsync(result.ManifestPath);
|
||||
manifest = manifest.Replace("\"count\":2", "\"count\":3");
|
||||
await File.WriteAllTextAsync(result.ManifestPath, manifest);
|
||||
|
||||
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
|
||||
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Contains(validation.Errors, e => e.Contains("count", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,99 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierHealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ConcelierHealthEndpointTests(WebApplicationFactory<Program> factory)
|
||||
public HealthWebAppFactory()
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
// Ensure options binder sees required storage values before Program.Main executes.
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "mongodb://localhost:27017/test-health");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
var overrides = new Dictionary<string, string?>
|
||||
{
|
||||
{"Storage:Dsn", "mongodb://localhost:27017/test-health"},
|
||||
{"Storage:Driver", "mongo"},
|
||||
{"Storage:CommandTimeoutSeconds", "30"},
|
||||
{"Telemetry:Enabled", "false"}
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(overrides);
|
||||
});
|
||||
|
||||
builder.UseSetting("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
|
||||
builder.UseSetting("CONCELIER__STORAGE__DRIVER", "mongo");
|
||||
builder.UseSetting("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
builder.UseSetting("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
Storage = new ConcelierOptions.StorageOptions
|
||||
{
|
||||
Dsn = "mongodb://localhost:27017/test-health",
|
||||
Driver = "mongo",
|
||||
CommandTimeoutSeconds = 30
|
||||
},
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(sp => new ConfigureOptions<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.Storage ??= new ConcelierOptions.StorageOptions();
|
||||
opts.Storage.Driver = "mongo";
|
||||
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
|
||||
opts.Storage.CommandTimeoutSeconds = 30;
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
}));
|
||||
services.PostConfigure<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.Storage ??= new ConcelierOptions.StorageOptions();
|
||||
opts.Storage.Driver = "mongo";
|
||||
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
|
||||
opts.Storage.CommandTimeoutSeconds = 30;
|
||||
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class ConcelierHealthEndpointTests : IClassFixture<HealthWebAppFactory>
|
||||
{
|
||||
private readonly HealthWebAppFactory _factory;
|
||||
|
||||
public ConcelierHealthEndpointTests(HealthWebAppFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Health_requires_tenant_header()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Concelier.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Services;
|
||||
|
||||
public sealed class IncidentFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteReadDelete_RoundTripsIncident()
|
||||
{
|
||||
var temp = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "incident-store-tests", Path.GetRandomFileName()));
|
||||
var options = new ConcelierOptions
|
||||
{
|
||||
Evidence = new ConcelierOptions.EvidenceBundleOptions
|
||||
{
|
||||
RootAbsolute = temp.FullName,
|
||||
DefaultManifestFileName = "manifest.json",
|
||||
DefaultTransparencyFileName = "transparency.json",
|
||||
PipelineVersion = "git:test",
|
||||
},
|
||||
};
|
||||
|
||||
var now = new DateTimeOffset(2025, 11, 25, 12, 0, 0, TimeSpan.Zero);
|
||||
await IncidentFileStore.WriteAsync(options.Evidence!, "tenant-a", "ADV-1", "test-reason", 30, options.Evidence!.PipelineVersion, now, CancellationToken.None);
|
||||
|
||||
var status = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
|
||||
status.Should().NotBeNull();
|
||||
status!.Reason.Should().Be("test-reason");
|
||||
status.Active.Should().BeTrue();
|
||||
status.Tenant.Should().Be("tenant-a");
|
||||
status.AdvisoryKey.Should().Be("ADV-1");
|
||||
status.PipelineVersion.Should().Be("git:test");
|
||||
|
||||
await IncidentFileStore.DeleteAsync(options.Evidence!, "tenant-a", "ADV-1", CancellationToken.None);
|
||||
var afterDelete = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
|
||||
afterDelete.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<CopyOutputSymbolsToOutputDirectory>true</CopyOutputSymbolsToOutputDirectory>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
|
||||
|
||||
@@ -27,6 +27,8 @@ using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using static StellaOps.Concelier.WebService.Program;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Models;
|
||||
@@ -39,6 +41,7 @@ using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.WebService.Jobs;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
using StellaOps.Concelier.WebService;
|
||||
using Xunit.Sdk;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
@@ -73,7 +76,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
PrepareMongoEnvironment();
|
||||
if (TryStartExternalMongo(out var externalConnectionString))
|
||||
if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString))
|
||||
{
|
||||
_factory = new ConcelierApplicationFactory(externalConnectionString);
|
||||
}
|
||||
@@ -381,6 +384,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
|
||||
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
|
||||
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
|
||||
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
|
||||
|
||||
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
|
||||
detailResponse.EnsureSuccessStatusCode();
|
||||
@@ -390,6 +395,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
|
||||
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -713,6 +719,66 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Attestation")]
|
||||
public async Task InternalAttestationVerify_ReturnsClaims()
|
||||
{
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
|
||||
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
|
||||
var manifestPath = Path.Combine(sampleDir, "manifest.json");
|
||||
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
||||
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
|
||||
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
|
||||
|
||||
var claims = JsonSerializer.Deserialize<AttestationClaims>(
|
||||
responseBody,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
Assert.NotNull(claims);
|
||||
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
|
||||
Assert.Equal("git:test-sha", claims.PipelineVersion);
|
||||
Assert.Equal(tarPath, claims.EvidenceBundlePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
|
||||
|
||||
var request = new EvidenceBatchRequest(
|
||||
new[]
|
||||
{
|
||||
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
|
||||
},
|
||||
ObservationLimit: 5,
|
||||
LinksetLimit: 5);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var item = Assert.Single(payload!.Items);
|
||||
Assert.Equal("component-a", item.ComponentId);
|
||||
Assert.Empty(item.Observations);
|
||||
Assert.Empty(item.Linksets);
|
||||
Assert.False(item.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
|
||||
{
|
||||
@@ -1300,7 +1366,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
|
||||
Assert.NotNull(payload);
|
||||
var conflict = Assert.Single(payload!.Conflicts);
|
||||
var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
|
||||
var conflict = Assert.Single(conflicts);
|
||||
Assert.Equal(conflictId, conflict.ConflictId);
|
||||
Assert.Equal("severity", conflict.Explainer.Type);
|
||||
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
||||
@@ -1977,6 +2044,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
|
||||
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
|
||||
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
|
||||
|
||||
var opensslPath = ResolveOpenSsl11Path();
|
||||
if (!string.IsNullOrEmpty(opensslPath))
|
||||
{
|
||||
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
||||
var merged = string.IsNullOrWhiteSpace(currentLd)
|
||||
? opensslPath
|
||||
: string.Join(':', opensslPath, currentLd);
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
@@ -1984,6 +2062,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
|
||||
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
|
||||
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
|
||||
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
|
||||
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
|
||||
{
|
||||
@@ -2002,6 +2084,23 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveOpenSsl11Path()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = Path.GetFullPath(Path.Combine(current, ".."));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
@@ -2035,7 +2134,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
options.Telemetry.EnableMetrics = false;
|
||||
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
_authorityConfigure?.Invoke(options.Authority);
|
||||
|
||||
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
options.Evidence.Root = repoRoot;
|
||||
options.Evidence.RootAbsolute = repoRoot;
|
||||
});
|
||||
|
||||
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
var wwwroot = Path.Combine(contentRoot, "wwwroot");
|
||||
Directory.CreateDirectory(wwwroot);
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
@@ -3093,4 +3202,5 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(map);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
44
src/Excititor/AGENTS.md
Normal file
44
src/Excititor/AGENTS.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Excititor · AGENTS Charter (Air-Gap & Trust Connectors)
|
||||
|
||||
## Module Scope & Working Directory
|
||||
- Working directory: `src/Excititor/**` (WebService, Worker, __Libraries, __Tests, connectors, scripts). No cross-module edits unless explicitly noted in sprint Decisions & Risks.
|
||||
- Mission (current sprint): air-gap parity for evidence chunks, trust connector wiring, and attestation verification aligned to Evidence Locker contract.
|
||||
|
||||
## Roles
|
||||
- **Backend engineer (ASP.NET Core / Mongo):** chunk ingestion/export, attestation verifier, trust connector.
|
||||
- **Air-Gap/Platform engineer:** sealed-mode switches, offline bundles, deterministic cache/path handling.
|
||||
- **QA automation:** WebApplicationFactory + Mongo2Go tests for chunk APIs, attestations, and trust connector; deterministic ordering/hashes.
|
||||
- **Docs/Schema steward:** keep chunk API, attestation plan, and trust connector docs in sync with behavior; update schemas and samples.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/excititor/architecture.md`
|
||||
- `docs/modules/excititor/attestation-plan.md`
|
||||
- `docs/modules/excititor/operations/chunk-api-user-guide.md`
|
||||
- `docs/modules/excititor/schemas/vex-chunk-api.yaml`
|
||||
- `docs/modules/evidence-locker/attestation-contract.md`
|
||||
|
||||
## Working Agreements
|
||||
- Determinism: canonical JSON ordering; stable pagination; UTC ISO-8601 timestamps; sort chunk edges deterministically.
|
||||
- Offline-first: default sealed-mode must not reach external networks; connectors obey allowlist; feature flags default safe.
|
||||
- Attestation: DSSE/Envelope per contract; always include tenant/source identifiers; validation fixtures required.
|
||||
- Tenant safety: enforce tenant headers/guards on every API; no cross-tenant leakage.
|
||||
- Logging/metrics: structured logs; meters under `StellaOps.Excititor.*`; tag `tenant`, `source`, `result`.
|
||||
- Cross-module edits: require sprint note; otherwise, stay within Excititor working dir.
|
||||
|
||||
## Testing Rules
|
||||
- Use Mongo2Go/in-memory fixtures; avoid network.
|
||||
- API tests in `StellaOps.Excititor.WebService.Tests`; worker/connectors in `StellaOps.Excititor.Worker.Tests`; shared fixtures in `__Tests`.
|
||||
- Tests must assert determinism (ordering/hashes), tenant enforcement, and sealed-mode behavior.
|
||||
|
||||
## Delivery Discipline
|
||||
- Update sprint tracker status (`TODO → DOING → DONE/BLOCKED`) for each task; mirror changes in Execution Log and Decisions & Risks.
|
||||
- When changing contracts (API/attestation schemas), update docs and samples and link from sprint Decisions & Risks.
|
||||
- If a decision is needed, mark the task BLOCKED and record the decision ask—do not pause work.
|
||||
|
||||
## Tooling/Env Notes
|
||||
- .NET 10 with preview features enabled; Mongo driver ≥ 3.x.
|
||||
- Signing/verifier hooks rely on Evidence Locker contract fixtures under `docs/modules/evidence-locker/`.
|
||||
- Sealed-mode tests should run with `EXCITITOR_SEALED=1` (env var) to enforce offline code paths.
|
||||
@@ -21,6 +21,9 @@ public sealed class AirgapImportRequest
|
||||
[JsonPropertyName("publisher")]
|
||||
public string? Publisher { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("payloadHash")]
|
||||
public string? PayloadHash { get; init; }
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Options;
|
||||
|
||||
internal sealed class AirgapOptions
|
||||
{
|
||||
public const string SectionName = "Excititor:Airgap";
|
||||
|
||||
/// <summary>
|
||||
/// Enables sealed-mode enforcement for air-gapped imports.
|
||||
/// When true, external payload URLs are rejected and publisher allowlist is applied.
|
||||
/// </summary>
|
||||
public bool SealedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, imports must originate from mirror/offline sources (no HTTP/HTTPS URLs).
|
||||
/// </summary>
|
||||
public bool MirrorOnly { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional allowlist of publishers that may submit bundles while sealed mode is enabled.
|
||||
/// Empty list means allow all.
|
||||
/// </summary>
|
||||
public List<string> TrustedPublishers { get; } = new();
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
@@ -54,8 +53,10 @@ services.AddCycloneDxNormalizer();
|
||||
services.AddOpenVexNormalizer();
|
||||
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
|
||||
// TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized.
|
||||
services.Configure<AirgapOptions>(configuration.GetSection(AirgapOptions.SectionName));
|
||||
services.AddSingleton<AirgapImportValidator>();
|
||||
services.AddSingleton<AirgapSignerTrustService>();
|
||||
services.AddSingleton<AirgapModeEnforcer>();
|
||||
services.AddSingleton<ConsoleTelemetry>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
|
||||
@@ -185,7 +186,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
get = new
|
||||
{
|
||||
summary = "Service status (aggregation-only metadata)",
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -219,7 +220,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
get = new
|
||||
{
|
||||
summary = "Health check",
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -254,7 +255,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false, description = "Numeric cursor or Last-Event-ID" },
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }
|
||||
},
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -331,7 +332,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
}
|
||||
}
|
||||
},
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new { description = "Accepted" },
|
||||
["400"] = new
|
||||
@@ -448,16 +449,47 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromServices] AirgapImportValidator validator,
|
||||
[FromServices] AirgapSignerTrustService trustService,
|
||||
[FromServices] AirgapModeEnforcer modeEnforcer,
|
||||
[FromServices] IAirgapImportStore store,
|
||||
[FromServices] ILoggerFactory loggerFactory,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromBody] AirgapImportRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("AirgapImport");
|
||||
var nowUtc = timeProvider.GetUtcNow();
|
||||
var tenantId = string.IsNullOrWhiteSpace(request.TenantId)
|
||||
? "default"
|
||||
: request.TenantId!.Trim().ToLowerInvariant();
|
||||
var stalenessSeconds = request.SignedAt is null
|
||||
? (int?)null
|
||||
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
|
||||
|
||||
var timeline = new List<AirgapTimelineEntry>();
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null)
|
||||
{
|
||||
var entry = new AirgapTimelineEntry
|
||||
{
|
||||
EventType = eventType,
|
||||
CreatedAt = nowUtc,
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId ?? string.Empty,
|
||||
MirrorGeneration = request.MirrorGeneration ?? string.Empty,
|
||||
StalenessSeconds = stalenessSeconds,
|
||||
ErrorCode = code,
|
||||
Message = message
|
||||
};
|
||||
timeline.Add(entry);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
|
||||
}
|
||||
|
||||
RecordEvent("airgap.import.started");
|
||||
|
||||
var errors = validator.Validate(request, nowUtc);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var first = errors[0];
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message);
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
@@ -468,8 +500,22 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
});
|
||||
}
|
||||
|
||||
if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", sealedCode, sealedMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = sealedCode,
|
||||
message = sealedMessage
|
||||
}
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (!trustService.Validate(request, out var trustCode, out var trustMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", trustCode, trustMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
@@ -480,9 +526,16 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
|
||||
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
|
||||
RecordEvent("airgap.import.completed");
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = $"{request.BundleId}:{request.MirrorGeneration}",
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId!,
|
||||
MirrorGeneration = request.MirrorGeneration!,
|
||||
SignedAt = request.SignedAt!.Value,
|
||||
@@ -491,7 +544,11 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
PayloadUrl = request.PayloadUrl,
|
||||
Signature = request.Signature!,
|
||||
TransparencyLog = request.TransparencyLog,
|
||||
ImportedAt = nowUtc
|
||||
ImportedAt = nowUtc,
|
||||
PortableManifestPath = manifestPath,
|
||||
PortableManifestHash = manifestHash,
|
||||
EvidenceLockerPath = evidenceLockerPath,
|
||||
Timeline = timeline
|
||||
};
|
||||
|
||||
try
|
||||
@@ -500,6 +557,7 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
}
|
||||
catch (DuplicateAirgapImportException dup)
|
||||
{
|
||||
RecordEvent("airgap.import.failed", "AIRGAP_IMPORT_DUPLICATE", dup.Message);
|
||||
return Results.Conflict(new
|
||||
{
|
||||
error = new
|
||||
@@ -513,10 +571,20 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
|
||||
{
|
||||
bundleId = request.BundleId,
|
||||
generation = request.MirrorGeneration
|
||||
generation = request.MirrorGeneration,
|
||||
manifest = manifestPath,
|
||||
evidence = evidenceLockerPath,
|
||||
manifestSha256 = manifestHash
|
||||
});
|
||||
});
|
||||
|
||||
static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
[FromBody] AttestationVerifyRequest request,
|
||||
@@ -1548,6 +1616,15 @@ app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
|
||||
|
||||
app.Run();
|
||||
|
||||
internal sealed record ExcititorTimelineEvent(
|
||||
string Type,
|
||||
string Tenant,
|
||||
string Source,
|
||||
int Count,
|
||||
int Errors,
|
||||
string? TraceId,
|
||||
string OccurredAt);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal sealed class AirgapModeEnforcer
|
||||
{
|
||||
private readonly AirgapOptions _options;
|
||||
private readonly ILogger<AirgapModeEnforcer> _logger;
|
||||
|
||||
public AirgapModeEnforcer(IOptions<AirgapOptions> options, ILogger<AirgapModeEnforcer> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Validate(AirgapImportRequest request, out string? errorCode, out string? message)
|
||||
{
|
||||
errorCode = null;
|
||||
message = null;
|
||||
|
||||
if (!_options.SealedMode)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_options.MirrorOnly && !string.IsNullOrWhiteSpace(request.PayloadUrl) && LooksLikeExternal(request.PayloadUrl))
|
||||
{
|
||||
errorCode = "AIRGAP_EGRESS_BLOCKED";
|
||||
message = "Sealed mode forbids external payload URLs; stage bundle via mirror/portable media.";
|
||||
_logger.LogWarning("Blocked airgap import because payloadUrl points to external location: {Url}", request.PayloadUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_options.TrustedPublishers.Count > 0 && !string.IsNullOrWhiteSpace(request.Publisher))
|
||||
{
|
||||
var allowed = _options.TrustedPublishers.Any(p => string.Equals(p, request.Publisher, StringComparison.OrdinalIgnoreCase));
|
||||
if (!allowed)
|
||||
{
|
||||
errorCode = "AIRGAP_SOURCE_UNTRUSTED";
|
||||
message = $"Publisher '{request.Publisher}' is not allowlisted for sealed-mode imports.";
|
||||
_logger.LogWarning("Blocked airgap import because publisher {Publisher} is not allowlisted.", request.Publisher);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool LooksLikeExternal(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AirgapTimelineEntry
|
||||
{
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
public string MirrorGeneration { get; set; } = string.Empty;
|
||||
|
||||
public int? StalenessSeconds { get; set; }
|
||||
= null;
|
||||
|
||||
public string? ErrorCode { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Message { get; set; }
|
||||
= null;
|
||||
}
|
||||
@@ -316,6 +316,8 @@ public sealed class AirgapImportRecord
|
||||
[BsonId]
|
||||
public string Id { get; set; } = default!;
|
||||
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
public string BundleId { get; set; } = default!;
|
||||
|
||||
public string MirrorGeneration { get; set; } = default!;
|
||||
@@ -333,6 +335,14 @@ public sealed class AirgapImportRecord
|
||||
public string? TransparencyLog { get; set; } = null;
|
||||
|
||||
public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string PortableManifestPath { get; set; } = string.Empty;
|
||||
|
||||
public string PortableManifestHash { get; set; } = string.Empty;
|
||||
|
||||
public string EvidenceLockerPath { get; set; } = string.Empty;
|
||||
|
||||
public List<AirgapTimelineEntry> Timeline { get; set; } = new();
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
|
||||
@@ -31,8 +31,15 @@ public sealed class AirgapImportValidatorTests
|
||||
[Fact]
|
||||
public void Validate_InvalidHash_ReturnsError()
|
||||
{
|
||||
var req = Valid();
|
||||
req.PayloadHash = "not-a-hash";
|
||||
var req = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "5",
|
||||
Publisher = "stellaops",
|
||||
PayloadHash = "not-a-hash",
|
||||
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
|
||||
SignedAt = _now
|
||||
};
|
||||
|
||||
var result = _validator.Validate(req, _now);
|
||||
|
||||
@@ -42,8 +49,15 @@ public sealed class AirgapImportValidatorTests
|
||||
[Fact]
|
||||
public void Validate_InvalidSignature_ReturnsError()
|
||||
{
|
||||
var req = Valid();
|
||||
req.Signature = "???";
|
||||
var req = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "5",
|
||||
Publisher = "stellaops",
|
||||
PayloadHash = "sha256:" + new string('b', 64),
|
||||
Signature = "???",
|
||||
SignedAt = _now
|
||||
};
|
||||
|
||||
var result = _validator.Validate(req, _now);
|
||||
|
||||
@@ -53,8 +67,15 @@ public sealed class AirgapImportValidatorTests
|
||||
[Fact]
|
||||
public void Validate_MirrorGenerationNonNumeric_ReturnsError()
|
||||
{
|
||||
var req = Valid();
|
||||
req.MirrorGeneration = "abc";
|
||||
var req = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "abc",
|
||||
Publisher = "stellaops",
|
||||
PayloadHash = "sha256:" + new string('b', 64),
|
||||
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
|
||||
SignedAt = _now
|
||||
};
|
||||
|
||||
var result = _validator.Validate(req, _now);
|
||||
|
||||
@@ -64,8 +85,15 @@ public sealed class AirgapImportValidatorTests
|
||||
[Fact]
|
||||
public void Validate_SignedAtTooOld_ReturnsError()
|
||||
{
|
||||
var req = Valid();
|
||||
req.SignedAt = _now.AddSeconds(-10);
|
||||
var req = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "5",
|
||||
Publisher = "stellaops",
|
||||
PayloadHash = "sha256:" + new string('b', 64),
|
||||
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
|
||||
SignedAt = _now.AddSeconds(-10)
|
||||
};
|
||||
|
||||
var result = _validator.Validate(req, _now);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Options;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public class AirgapModeEnforcerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Allows_WhenNotSealed()
|
||||
{
|
||||
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = false }), NullLogger<AirgapModeEnforcer>.Instance);
|
||||
var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(code);
|
||||
Assert.Null(message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Blocks_ExternalUrl_WhenSealed()
|
||||
{
|
||||
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, MirrorOnly = true }), NullLogger<AirgapModeEnforcer>.Instance);
|
||||
var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("AIRGAP_EGRESS_BLOCKED", code);
|
||||
Assert.NotNull(message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Blocks_Untrusted_Publisher_WhenAllowlistSet()
|
||||
{
|
||||
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, TrustedPublishers = { "mirror-a" } }), NullLogger<AirgapModeEnforcer>.Instance);
|
||||
var ok = enforcer.Validate(new AirgapImportRequest { Publisher = "mirror-b" }, out var code, out var message);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("AIRGAP_SOURCE_UNTRUSTED", code);
|
||||
Assert.NotNull(message);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@
|
||||
<ItemGroup>
|
||||
<Compile Remove="**/*.cs" />
|
||||
<Compile Include="AirgapImportEndpointTests.cs" />
|
||||
<Compile Include="AirgapImportValidatorTests.cs" />
|
||||
<Compile Include="AirgapModeEnforcerTests.cs" />
|
||||
<Compile Include="EvidenceTelemetryTests.cs" />
|
||||
<Compile Include="DevRuntimeEnvironmentStub.cs" />
|
||||
<Compile Include="TestAuthentication.cs" />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class AttestationEventEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly NotifierApplicationFactory _factory;
|
||||
|
||||
public AttestationEventEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attestation_event_is_published_to_queue()
|
||||
{
|
||||
var recordingQueue = new RecordingNotifyEventQueue();
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<INotifyEventQueue>();
|
||||
services.AddSingleton<INotifyEventQueue>(recordingQueue);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var request = new AttestationEventRequest
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Kind = "authority.keys.rotated",
|
||||
Actor = "authority",
|
||||
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Payload = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["rotation"] = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["batchId"] = "batch-42",
|
||||
["executedAt"] = "2025-11-24T00:00:00Z"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/attestation-events")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
|
||||
|
||||
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Single(recordingQueue.Published);
|
||||
|
||||
var published = recordingQueue.Published.Single();
|
||||
Assert.Equal("authority.keys.rotated", published.Event.Kind);
|
||||
Assert.Equal("tenant-sample", published.Event.Tenant);
|
||||
Assert.Equal("notify:events", published.Stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class AttestationTemplateSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
|
||||
{
|
||||
var templateRepo = new InMemoryTemplateRepository();
|
||||
var channelRepo = new InMemoryChannelRepository();
|
||||
var ruleRepo = new InMemoryRuleRepository();
|
||||
var logger = NullLogger<AttestationTemplateSeeder>.Instance;
|
||||
|
||||
var contentRoot = LocateRepoRoot();
|
||||
|
||||
var seededTemplates = await AttestationTemplateSeeder.SeedTemplatesAsync(
|
||||
templateRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var seededRouting = await AttestationTemplateSeeder.SeedRoutingAsync(
|
||||
channelRepo,
|
||||
ruleRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(seededTemplates >= 6, "Expected attestation templates to be seeded.");
|
||||
Assert.True(seededRouting >= 3, "Expected attestation routing seed to create channels and rules.");
|
||||
|
||||
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-attest-key-rotation");
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-attest-transparency-anomaly");
|
||||
|
||||
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("authority.keys.rotated"));
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("attestor.transparency.anomaly"));
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notify.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class RiskEventEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly NotifierApplicationFactory _factory;
|
||||
|
||||
public RiskEventEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Risk_event_is_published_to_queue()
|
||||
{
|
||||
var recordingQueue = new RecordingNotifyEventQueue();
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<INotifyEventQueue>();
|
||||
services.AddSingleton<INotifyEventQueue>(recordingQueue);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var request = new RiskEventRequest
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Kind = "risk.profile.severity.changed",
|
||||
Actor = "risk-engine",
|
||||
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Payload = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["profile"] = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["id"] = "stellaops://risk/profile/example@2025.11",
|
||||
["version"] = "2025.11"
|
||||
},
|
||||
["previous"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "medium" },
|
||||
["current"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "high" }
|
||||
}
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/risk-events")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
|
||||
|
||||
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Single(recordingQueue.Published);
|
||||
|
||||
var published = recordingQueue.Published.Single();
|
||||
Assert.Equal("risk.profile.severity.changed", published.Event.Kind);
|
||||
Assert.Equal("tenant-sample", published.Event.Tenant);
|
||||
Assert.Equal("notify:events", published.Stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class RiskTemplateSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
|
||||
{
|
||||
var templateRepo = new InMemoryTemplateRepository();
|
||||
var channelRepo = new InMemoryChannelRepository();
|
||||
var ruleRepo = new InMemoryRuleRepository();
|
||||
var logger = NullLogger<RiskTemplateSeeder>.Instance;
|
||||
|
||||
var contentRoot = LocateRepoRoot();
|
||||
|
||||
var seededTemplates = await RiskTemplateSeeder.SeedTemplatesAsync(
|
||||
templateRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var seededRouting = await RiskTemplateSeeder.SeedRoutingAsync(
|
||||
channelRepo,
|
||||
ruleRepo,
|
||||
contentRoot,
|
||||
logger,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(seededTemplates >= 4, "Expected risk templates to be seeded.");
|
||||
Assert.True(seededRouting >= 4, "Expected risk routing seed to create channels and rules.");
|
||||
|
||||
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-risk-severity-change");
|
||||
Assert.Contains(templates, t => t.Key == "tmpl-risk-profile-state");
|
||||
|
||||
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.severity.changed"));
|
||||
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.published"));
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Notify.Queue;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class RecordingNotifyEventQueue : INotifyEventQueue
|
||||
{
|
||||
private readonly List<NotifyQueueEventMessage> _messages = new();
|
||||
|
||||
public IReadOnlyList<NotifyQueueEventMessage> Published => _messages;
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
|
||||
public ValueTask PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_messages.Add(message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
public sealed record AttestationEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
public sealed record RiskEventRequest
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
|
||||
/// </summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Actor { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
public JsonObject? Payload { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? ResumeToken { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -31,6 +32,8 @@ if (!isTesting)
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
builder.Services.AddHostedService<PackApprovalTemplateSeeder>();
|
||||
builder.Services.AddHostedService<AttestationTemplateSeeder>();
|
||||
builder.Services.AddHostedService<RiskTemplateSeeder>();
|
||||
}
|
||||
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
@@ -173,6 +176,122 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/attestation-events", async (
|
||||
HttpContext context,
|
||||
AttestationEventRequest request,
|
||||
INotifyEventQueue? eventQueue,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Kind))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
|
||||
}
|
||||
|
||||
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
|
||||
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
|
||||
|
||||
if (eventQueue is not null)
|
||||
{
|
||||
var payload = request.Payload ?? new JsonObject();
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: request.Kind!,
|
||||
tenant: tenantId,
|
||||
ts: ts,
|
||||
payload: payload,
|
||||
attributes: request.Attributes ?? new Dictionary<string, string>(),
|
||||
actor: request.Actor,
|
||||
version: "1");
|
||||
|
||||
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
|
||||
}
|
||||
|
||||
await eventQueue.PublishAsync(
|
||||
new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
stream: "notify:events",
|
||||
idempotencyKey: idempotencyKey,
|
||||
partitionKey: tenantId,
|
||||
traceId: context.TraceIdentifier),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
|
||||
{
|
||||
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/risk-events", async (
|
||||
HttpContext context,
|
||||
RiskEventRequest request,
|
||||
INotifyEventQueue? eventQueue,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Kind))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
|
||||
}
|
||||
|
||||
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
|
||||
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
|
||||
|
||||
if (eventQueue is not null)
|
||||
{
|
||||
var payload = request.Payload ?? new JsonObject();
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: request.Kind!,
|
||||
tenant: tenantId,
|
||||
ts: ts,
|
||||
payload: payload,
|
||||
attributes: request.Attributes ?? new Dictionary<string, string>(),
|
||||
actor: request.Actor,
|
||||
version: "1");
|
||||
|
||||
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
|
||||
}
|
||||
|
||||
await eventQueue.PublishAsync(
|
||||
new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
stream: "notify:events",
|
||||
idempotencyKey: idempotencyKey,
|
||||
partitionKey: tenantId,
|
||||
traceId: context.TraceIdentifier),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
|
||||
{
|
||||
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class AttestationTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<AttestationTemplateSeeder> _logger;
|
||||
|
||||
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
if (templateRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Template repository not registered; skipping attestation template seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = _environment.ContentRootPath;
|
||||
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (templatesSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded);
|
||||
}
|
||||
|
||||
if (channelRepo is null || ruleRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (routingSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded default attestation routing (channels + rules).");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public static async Task<int> SeedTemplatesAsync(
|
||||
INotifyTemplateRepository repository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var templateDir = LocateAttestationTemplatesPath(contentRootPath);
|
||||
if (templateDir is null)
|
||||
{
|
||||
logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static async Task<int> SeedRoutingAsync(
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelRepository);
|
||||
ArgumentNullException.ThrowIfNull(ruleRepository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var samplePath = LocateAttestationRulesPath(contentRootPath);
|
||||
if (samplePath is null)
|
||||
{
|
||||
logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(samplePath);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenant = "bootstrap";
|
||||
var channelsElement = doc.RootElement.GetProperty("channels");
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
{
|
||||
var model = ToRule(rule, tenant);
|
||||
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return channels.Length + rulesElement.GetArrayLength();
|
||||
}
|
||||
|
||||
private static NotifyRule ToRule(JsonElement element, string tenant)
|
||||
{
|
||||
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
|
||||
var name = element.GetProperty("name").GetString() ?? ruleId;
|
||||
var enabled = element.GetProperty("enabled").GetBoolean();
|
||||
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
|
||||
|
||||
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
|
||||
NotifyRuleAction.Create(
|
||||
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
|
||||
channel: action.GetProperty("channel").GetString() ?? string.Empty,
|
||||
template: action.GetProperty("template").GetString() ?? string.Empty,
|
||||
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: tenant,
|
||||
name: name,
|
||||
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
|
||||
actions: actions,
|
||||
enabled: enabled,
|
||||
description: "Seeded attestation routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
var name = element.GetProperty("name").GetString() ?? channelId;
|
||||
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
|
||||
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
|
||||
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
|
||||
|
||||
var config = NotifyChannelConfig.Create(
|
||||
secretRef: secretRef,
|
||||
endpoint: endpoint,
|
||||
target: target);
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
|
||||
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
|
||||
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
|
||||
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
|
||||
var locale = root.GetProperty("locale").GetString() ?? "en-US";
|
||||
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
|
||||
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
|
||||
var body = root.GetProperty("body").GetString() ?? string.Empty;
|
||||
|
||||
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
|
||||
}
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: description,
|
||||
metadata: metadata,
|
||||
createdBy: "seed:attestation");
|
||||
}
|
||||
|
||||
private static string? LocateAttestationTemplatesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"),
|
||||
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(Directory.Exists);
|
||||
}
|
||||
|
||||
private static string? LocateAttestationRulesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
|
||||
/// </summary>
|
||||
public sealed class RiskTemplateSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<RiskTemplateSeeder> _logger;
|
||||
|
||||
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
|
||||
|
||||
if (templateRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Template repository not registered; skipping risk template seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var contentRoot = _environment.ContentRootPath;
|
||||
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (templatesSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded);
|
||||
}
|
||||
|
||||
if (channelRepo is null || ruleRepo is null)
|
||||
{
|
||||
_logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (routingSeeded > 0)
|
||||
{
|
||||
_logger.LogInformation("Seeded default risk routing (channels + rules).");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public static async Task<int> SeedTemplatesAsync(
|
||||
INotifyTemplateRepository repository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var templateDir = LocateRiskTemplatesPath(contentRootPath);
|
||||
if (templateDir is null)
|
||||
{
|
||||
logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static async Task<int> SeedRoutingAsync(
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
string contentRootPath,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelRepository);
|
||||
ArgumentNullException.ThrowIfNull(ruleRepository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var samplePath = LocateRiskRulesPath(contentRootPath);
|
||||
if (samplePath is null)
|
||||
{
|
||||
logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(samplePath);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenant = "bootstrap";
|
||||
var channelsElement = doc.RootElement.GetProperty("channels");
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
{
|
||||
var model = ToRule(rule, tenant);
|
||||
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return channels.Length + rulesElement.GetArrayLength();
|
||||
}
|
||||
|
||||
private static NotifyRule ToRule(JsonElement element, string tenant)
|
||||
{
|
||||
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
|
||||
var name = element.GetProperty("name").GetString() ?? ruleId;
|
||||
var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true;
|
||||
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
|
||||
|
||||
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
|
||||
NotifyRuleAction.Create(
|
||||
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
|
||||
channel: action.GetProperty("channel").GetString() ?? string.Empty,
|
||||
template: action.GetProperty("template").GetString() ?? string.Empty,
|
||||
locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
|
||||
throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default,
|
||||
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: ruleId,
|
||||
tenantId: tenant,
|
||||
name: name,
|
||||
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
|
||||
actions: actions,
|
||||
enabled: enabled,
|
||||
description: "Seeded risk routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
var name = element.GetProperty("name").GetString() ?? channelId;
|
||||
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
|
||||
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
|
||||
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
|
||||
|
||||
var config = NotifyChannelConfig.Create(
|
||||
secretRef: secretRef,
|
||||
endpoint: endpoint,
|
||||
target: target);
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
|
||||
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
|
||||
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
|
||||
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
|
||||
var locale = root.GetProperty("locale").GetString() ?? "en-US";
|
||||
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
|
||||
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
|
||||
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
|
||||
var body = root.GetProperty("body").GetString() ?? string.Empty;
|
||||
|
||||
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
|
||||
}
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: templateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: key,
|
||||
locale: locale,
|
||||
body: body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: description,
|
||||
metadata: metadata,
|
||||
createdBy: "seed:risk");
|
||||
}
|
||||
|
||||
private static string? LocateRiskTemplatesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"),
|
||||
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(Directory.Exists);
|
||||
}
|
||||
|
||||
private static string? LocateRiskRulesPath(string contentRootPath)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"),
|
||||
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"ruleId": "risk-severity-change",
|
||||
"name": "Risk severity escalation/downgrade",
|
||||
"enabled": true,
|
||||
"tenantId": "bootstrap",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"risk.profile.severity.changed"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "act-risk-severity-slack",
|
||||
"enabled": true,
|
||||
"channel": "slack-risk",
|
||||
"template": "tmpl-risk-severity-change",
|
||||
"locale": "en-us",
|
||||
"throttle": "PT5M"
|
||||
},
|
||||
{
|
||||
"actionId": "act-risk-severity-email",
|
||||
"enabled": true,
|
||||
"channel": "email-risk",
|
||||
"template": "tmpl-risk-severity-change",
|
||||
"locale": "en-us",
|
||||
"throttle": "PT10M"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "risk-profile-state",
|
||||
"name": "Risk profile published/deprecated",
|
||||
"enabled": true,
|
||||
"tenantId": "bootstrap",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"risk.profile.published",
|
||||
"risk.profile.deprecated",
|
||||
"risk.profile.thresholds.changed"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "act-risk-profile-slack",
|
||||
"enabled": true,
|
||||
"channel": "slack-risk",
|
||||
"template": "tmpl-risk-profile-state",
|
||||
"locale": "en-us"
|
||||
},
|
||||
{
|
||||
"actionId": "act-risk-profile-email",
|
||||
"enabled": true,
|
||||
"channel": "email-risk",
|
||||
"template": "tmpl-risk-profile-state",
|
||||
"locale": "en-us"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"channelId": "slack-risk",
|
||||
"type": "slack",
|
||||
"tenantId": "bootstrap",
|
||||
"name": "Slack · Risk",
|
||||
"endpoint": "https://hooks.slack.local/services/T000/B000/RISK",
|
||||
"secretRef": "ref://notify/channels/slack/risk"
|
||||
},
|
||||
{
|
||||
"channelId": "email-risk",
|
||||
"type": "email",
|
||||
"tenantId": "bootstrap",
|
||||
"name": "Email · Risk",
|
||||
"target": "risk-team@example.com",
|
||||
"secretRef": "ref://notify/channels/email/risk"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
| ID | Status | Owner(s) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Notifications Service Guild | Attestation template suite complete; Slack expiry template added; coverage tests guard required channels. |
|
||||
| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events + transparency witness failures (depends on 74-001). |
|
||||
| NOTIFY-ATTEST-74-002 | DONE (2025-11-24) | Notifications Service Guild · KMS Guild | Attestation event ingestion endpoint + seed routing/templates for key rotation, revocation, and transparency witness failures. |
|
||||
| NOTIFY-OAS-61-001 | DONE (2025-11-17) | Notifications Service Guild · API Contracts Guild | OAS updated with rules/templates/incidents/quiet hours and standard error envelope. |
|
||||
| NOTIFY-OAS-61-002 | DONE (2025-11-17) | Notifications Service Guild | `.well-known/openapi` discovery endpoint with scope metadata implemented. |
|
||||
| NOTIFY-OAS-62-001 | DONE (2025-11-17) | Notifications Service Guild · SDK Generator Guild | SDK usage examples + smoke tests (depends on 61-002). |
|
||||
| NOTIFY-OAS-63-001 | DONE (2025-11-17) | Notifications Service Guild · API Governance Guild | Deprecation headers + template notices for retiring APIs (depends on 62-001). |
|
||||
| NOTIFY-OBS-51-001 | DONE (2025-11-22) | Notifications Service Guild · Observability Guild | SLO webhook sink validated (`HttpEgressSloSinkTests`, `EventProcessorTests`); TRX: `StellaOps.Notifier.Tests/TestResults/notifier-slo-tests.trx`. |
|
||||
| NOTIFY-OBS-55-001 | DONE (2025-11-22) | Notifications Service Guild · Ops Guild | Incident mode start/stop notifications; templates + importable rules with quiet-hour overrides and legal logging metadata. |
|
||||
| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild · Risk Engine Guild | Trigger risk severity escalation/downgrade notifications (waiting on Policy export). |
|
||||
| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild · Policy Guild | Notify when risk profiles publish/deprecate/threshold-change (depends on 66-001). |
|
||||
| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | Per-profile routing rules + quiet hours for risk alerts (depends on 67-001). |
|
||||
| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. |
|
||||
| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. |
|
||||
| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. |
|
||||
|
||||
@@ -26,3 +26,5 @@ Host signed Task Pack bundles with provenance and RBAC for Epic 12. Ensure pac
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
- 6. Registry API expectations: require `X-API-Key` when configured and tenant scoping via `X-StellaOps-Tenant` (or `tenantId` on upload). Content/provenance downloads must emit digest headers (`X-Content-Digest`, `X-Provenance-Digest`) and respect tenant allowlists.
|
||||
- 7. Lifecycle/parity/signature rotation endpoints require tenant headers; offline seed export supports per-tenant filtering and deterministic zip output. All mutating calls emit audit log entries (file `audit.ndjson` or Mongo `packs_audit_log`).
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.PacksRegistry.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IAttestationRepository
|
||||
{
|
||||
Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default);
|
||||
Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IAuditRepository
|
||||
{
|
||||
Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface ILifecycleRepository
|
||||
{
|
||||
Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IMirrorRepository
|
||||
{
|
||||
Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IPackRepository
|
||||
{
|
||||
Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IPackSignatureVerifier
|
||||
{
|
||||
Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IParityRepository
|
||||
{
|
||||
Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record AttestationRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string Type,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string? Notes = null);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable audit event emitted for registry actions.
|
||||
/// </summary>
|
||||
public sealed record AuditRecord(
|
||||
string? PackId,
|
||||
string TenantId,
|
||||
string Event,
|
||||
DateTimeOffset OccurredAtUtc,
|
||||
string? Actor = null,
|
||||
string? Notes = null);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record LifecycleRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string State,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record MirrorSourceRecord(
|
||||
string Id,
|
||||
string TenantId,
|
||||
Uri UpstreamUri,
|
||||
bool Enabled,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? Notes = null,
|
||||
DateTimeOffset? LastSuccessfulSyncUtc = null);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed class PackPolicyOptions
|
||||
{
|
||||
public bool RequireSignature { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical pack metadata stored by the registry.
|
||||
/// </summary>
|
||||
public sealed record PackRecord(
|
||||
string PackId,
|
||||
string Name,
|
||||
string Version,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
string? Signature,
|
||||
string? ProvenanceUri,
|
||||
string? ProvenanceDigest,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record ParityRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string Status,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class AttestationService
|
||||
{
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAttestationRepository _attestationRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationService(IPackRepository packRepository, IAttestationRepository attestationRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<AttestationRecord> UploadAsync(string packId, string tenantId, string type, byte[] content, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for attestation upload.");
|
||||
}
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
var record = new AttestationRecord(packId.Trim(), tenantId.Trim(), type.Trim(), digest, _timeProvider.GetUtcNow(), notes?.Trim());
|
||||
await _attestationRepository.UpsertAsync(record, content, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "attestation.uploaded", record.CreatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
return _attestationRepository.GetAsync(packId.Trim(), type.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _attestationRepository.ListAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
return _attestationRepository.GetContentAsync(packId.Trim(), type.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ComplianceService
|
||||
{
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
|
||||
public ComplianceService(IPackRepository packRepository, IParityRepository parityRepository, ILifecycleRepository lifecycleRepository)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
}
|
||||
|
||||
public async Task<ComplianceSummary> SummarizeAsync(string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var total = packs.Count;
|
||||
var unsigned = packs.Count(p => string.IsNullOrWhiteSpace(p.Signature));
|
||||
var promoted = lifecycle.Count(l => string.Equals(l.State, "promoted", StringComparison.OrdinalIgnoreCase));
|
||||
var deprecated = lifecycle.Count(l => string.Equals(l.State, "deprecated", StringComparison.OrdinalIgnoreCase));
|
||||
var parityReady = parity.Count(p => string.Equals(p.Status, "ready", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new ComplianceSummary(total, unsigned, promoted, deprecated, parityReady);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComplianceSummary(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ExportService
|
||||
{
|
||||
private static readonly DateTimeOffset ZipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ExportService(
|
||||
IPackRepository packRepository,
|
||||
IParityRepository parityRepository,
|
||||
ILifecycleRepository lifecycleRepository,
|
||||
IAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> ExportOfflineSeedAsync(string? tenantId, bool includeContent, bool includeProvenance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var audits = await _auditRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
WriteNdjson(archive, "packs.ndjson", packs.OrderBy(p => p.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "parity.ndjson", parity.OrderBy(p => p.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "lifecycle.ndjson", lifecycle.OrderBy(l => l.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "audit.ndjson", audits.OrderBy(a => a.OccurredAtUtc).ThenBy(a => a.PackId, StringComparer.Ordinal));
|
||||
|
||||
if (includeContent)
|
||||
{
|
||||
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
|
||||
{
|
||||
var content = await _packRepository.GetContentAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null || content.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry($"content/{pack.PackId}.bin", CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(content.AsMemory(0, content.Length), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeProvenance)
|
||||
{
|
||||
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
|
||||
{
|
||||
var provenance = await _packRepository.GetProvenanceAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
if (provenance is null || provenance.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry($"provenance/{pack.PackId}.json", CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(provenance.AsMemory(0, provenance.Length), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
var auditTenant = string.IsNullOrWhiteSpace(tenantId) ? "*" : tenantId.Trim();
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, auditTenant, "offline.seed.exported", _timeProvider.GetUtcNow(), null, includeContent ? "with-content" : null), cancellationToken).ConfigureAwait(false);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private void WriteNdjson<T>(ZipArchive archive, string entryName, IEnumerable<T> records)
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
foreach (var record in records)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
writer.WriteLine(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class LifecycleService
|
||||
{
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly HashSet<string> AllowedStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"promoted", "deprecated", "draft"
|
||||
};
|
||||
|
||||
public LifecycleService(ILifecycleRepository lifecycleRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<LifecycleRecord> SetStateAsync(string packId, string tenantId, string state, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(state);
|
||||
|
||||
if (!AllowedStates.Contains(state))
|
||||
{
|
||||
throw new InvalidOperationException($"State '{state}' is not allowed (use: {string.Join(',', AllowedStates)}).");
|
||||
}
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
}
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for lifecycle update.");
|
||||
}
|
||||
|
||||
var record = new LifecycleRecord(packId.Trim(), tenantId.Trim(), state.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
|
||||
await _lifecycleRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "lifecycle.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _lifecycleRepository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _lifecycleRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class MirrorService
|
||||
{
|
||||
private readonly IMirrorRepository _mirrorRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public MirrorService(IMirrorRepository mirrorRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_mirrorRepository = mirrorRepository ?? throw new ArgumentNullException(nameof(mirrorRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<MirrorSourceRecord> UpsertAsync(string id, string tenantId, Uri upstreamUri, bool enabled, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(upstreamUri);
|
||||
|
||||
var record = new MirrorSourceRecord(id.Trim(), tenantId.Trim(), upstreamUri, enabled, enabled ? "enabled" : "disabled", _timeProvider.GetUtcNow(), notes?.Trim(), null);
|
||||
await _mirrorRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.upserted", record.UpdatedAtUtc, null, upstreamUri.ToString()), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _mirrorRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
|
||||
public async Task<MirrorSourceRecord?> MarkSyncAsync(string id, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _mirrorRepository.GetAsync(id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(existing.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for mirror sync update.");
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Status = status.Trim(),
|
||||
UpdatedAtUtc = _timeProvider.GetUtcNow(),
|
||||
LastSuccessfulSyncUtc = string.Equals(status, "synced", StringComparison.OrdinalIgnoreCase) ? _timeProvider.GetUtcNow() : existing.LastSuccessfulSyncUtc,
|
||||
Notes = notes ?? existing.Notes
|
||||
};
|
||||
|
||||
await _mirrorRepository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.sync", updated.UpdatedAtUtc, null, status), cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class PackService
|
||||
{
|
||||
private readonly IPackRepository _repository;
|
||||
private readonly IPackSignatureVerifier _signatureVerifier;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PackPolicyOptions _policy;
|
||||
|
||||
public PackService(IPackRepository repository, IPackSignatureVerifier signatureVerifier, IAuditRepository auditRepository, PackPolicyOptions? policy = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_policy = policy ?? new PackPolicyOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PackRecord> UploadAsync(
|
||||
string name,
|
||||
string version,
|
||||
string tenantId,
|
||||
byte[] content,
|
||||
string? signature,
|
||||
string? provenanceUri,
|
||||
byte[]? provenanceContent,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
|
||||
if (_policy.RequireSignature && string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
throw new InvalidOperationException("Signature is required by policy.");
|
||||
}
|
||||
|
||||
var valid = await _signatureVerifier.VerifyAsync(content, digest, signature, cancellationToken).ConfigureAwait(false);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Signature validation failed for uploaded pack.");
|
||||
}
|
||||
|
||||
string? provenanceDigest = null;
|
||||
if (provenanceContent is { Length: > 0 })
|
||||
{
|
||||
provenanceDigest = ComputeSha256(provenanceContent);
|
||||
}
|
||||
|
||||
var packId = BuildPackId(name, version);
|
||||
var record = new PackRecord(
|
||||
PackId: packId,
|
||||
Name: name.Trim(),
|
||||
Version: version.Trim(),
|
||||
TenantId: tenantId.Trim(),
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
ProvenanceUri: provenanceUri,
|
||||
ProvenanceDigest: provenanceDigest,
|
||||
CreatedAtUtc: _timeProvider.GetUtcNow(),
|
||||
Metadata: metadata);
|
||||
|
||||
await _repository.UpsertAsync(record, content, provenanceContent, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "pack.uploaded", record.CreatedAtUtc, null, provenanceUri), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetContentAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetProvenanceAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _repository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
|
||||
public async Task<PackRecord> RotateSignatureAsync(
|
||||
string packId,
|
||||
string tenantId,
|
||||
string newSignature,
|
||||
IPackSignatureVerifier? verifierOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newSignature);
|
||||
|
||||
var record = await _repository.GetAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
|
||||
if (!string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for signature rotation.");
|
||||
}
|
||||
|
||||
var content = await _repository.GetContentAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Pack content missing; cannot rotate signature.");
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
var verifier = verifierOverride ?? _signatureVerifier;
|
||||
var valid = await verifier.VerifyAsync(content, digest, newSignature, cancellationToken).ConfigureAwait(false);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Signature validation failed during rotation.");
|
||||
}
|
||||
|
||||
var provenance = await _repository.GetProvenanceAsync(packId.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var updated = record with { Signature = newSignature, Digest = digest };
|
||||
await _repository.UpsertAsync(updated, content, provenance, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "signature.rotated", _timeProvider.GetUtcNow(), null, null), cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildPackId(string name, string version)
|
||||
{
|
||||
var cleanName = name.Trim().ToLowerInvariant().Replace(' ', '-');
|
||||
var cleanVersion = version.Trim().ToLowerInvariant();
|
||||
return $"{cleanName}@{cleanVersion}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ParityService
|
||||
{
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ParityService(IParityRepository parityRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ParityRecord> SetStatusAsync(string packId, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
}
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for parity update.");
|
||||
}
|
||||
|
||||
var record = new ParityRecord(packId.Trim(), tenantId.Trim(), status.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
|
||||
await _parityRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "parity.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _parityRepository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _parityRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.PacksRegistry.Infrastructure;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FileAttestationRepository : IAttestationRepository
|
||||
{
|
||||
private readonly string _indexPath;
|
||||
private readonly string _contentPath;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FileAttestationRepository(string rootPath)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
|
||||
Directory.CreateDirectory(root);
|
||||
_indexPath = Path.Combine(root, "attestations.ndjson");
|
||||
_contentPath = Path.Combine(root, "attestations");
|
||||
Directory.CreateDirectory(_contentPath);
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fileName = GetFileName(record.PackId, record.Type);
|
||||
await File.WriteAllBytesAsync(fileName, content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await ListAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
return list.LastOrDefault(r => string.Equals(r.Type, type, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_indexPath))
|
||||
{
|
||||
return Array.Empty<AttestationRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
|
||||
return lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<AttestationRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null && string.Equals(r!.PackId, packId, StringComparison.OrdinalIgnoreCase))
|
||||
.Cast<AttestationRecord>()
|
||||
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = GetFileName(packId, type);
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
return File.ReadAllBytesAsync(file, cancellationToken).ContinueWith(t => (byte[]?)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
private string GetFileName(string packId, string type)
|
||||
{
|
||||
var safe = packId.Replace('/', '_').Replace('@', '_');
|
||||
var safeType = type.Replace('/', '_');
|
||||
return Path.Combine(_contentPath, $"{safe}_{safeType}.bin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FileAuditRepository : IAuditRepository
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FileAuditRepository(string rootPath)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
|
||||
Directory.CreateDirectory(root);
|
||||
_path = Path.Combine(root, "audit.ndjson");
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return Array.Empty<AuditRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
IEnumerable<AuditRecord> records = lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<AuditRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)!
|
||||
.Cast<AuditRecord>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return records
|
||||
.OrderBy(r => r.OccurredAtUtc)
|
||||
.ThenBy(r => r.PackId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FileLifecycleRepository : ILifecycleRepository
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FileLifecycleRepository(string rootPath)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
|
||||
Directory.CreateDirectory(root);
|
||||
_path = Path.Combine(root, "lifecycle.ndjson");
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
return lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)
|
||||
.Cast<LifecycleRecord>()
|
||||
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return Array.Empty<LifecycleRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
IEnumerable<LifecycleRecord> records = lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)!
|
||||
.Cast<LifecycleRecord>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FileMirrorRepository : IMirrorRepository
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FileMirrorRepository(string rootPath)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
|
||||
Directory.CreateDirectory(root);
|
||||
_path = Path.Combine(root, "mirrors.ndjson");
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return Array.Empty<MirrorSourceRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
IEnumerable<MirrorSourceRecord> records = lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<MirrorSourceRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)!
|
||||
.Cast<MirrorSourceRecord>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await ListAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
return list.LastOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FilePackRepository : IPackRepository
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly string _indexPath;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FilePackRepository(string root)
|
||||
{
|
||||
_root = string.IsNullOrWhiteSpace(root) ? Path.GetFullPath("data/packs") : Path.GetFullPath(root);
|
||||
_indexPath = Path.Combine(_root, "index.ndjson");
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
Directory.CreateDirectory(_root);
|
||||
Directory.CreateDirectory(Path.Combine(_root, "blobs"));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
|
||||
await File.WriteAllBytesAsync(blobPath, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (provenance is { Length: > 0 } && record.ProvenanceDigest is not null)
|
||||
{
|
||||
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(provPath)!);
|
||||
await File.WriteAllBytesAsync(provPath, provenance, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
return records.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return records
|
||||
.OrderBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(r => r.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<PackRecord>> ReadAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_indexPath))
|
||||
{
|
||||
return Array.Empty<PackRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
|
||||
return lines
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.Select(line => JsonSerializer.Deserialize<PackRecord>(line, _jsonOptions))
|
||||
.Where(r => r is not null)!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
|
||||
if (!File.Exists(blobPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await File.ReadAllBytesAsync(blobPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record?.ProvenanceDigest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
|
||||
if (!File.Exists(provPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await File.ReadAllBytesAsync(provPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
public sealed class FileParityRepository : IParityRepository
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
public FileParityRepository(string rootPath)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
|
||||
Directory.CreateDirectory(root);
|
||||
_path = Path.Combine(root, "parity.ndjson");
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// naive append; last write wins on read
|
||||
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
await using var writer = new StreamWriter(stream);
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
return lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)
|
||||
.Cast<ParityRecord>()
|
||||
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return Array.Empty<ParityRecord>();
|
||||
}
|
||||
|
||||
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
|
||||
IEnumerable<ParityRecord> records = lines
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
|
||||
.Where(r => r is not null)!
|
||||
.Cast<ParityRecord>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mutex.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
public sealed class InMemoryAttestationRepository : IAttestationRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string PackId, string Type), AttestationRecord> _records = new();
|
||||
private readonly ConcurrentDictionary<(string PackId, string Type), byte[]> _content = new();
|
||||
|
||||
public Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
_records[(record.PackId, record.Type)] = record;
|
||||
_content[(record.PackId, record.Type)] = content.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_records.TryGetValue((packId, type), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _records.Values.Where(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestationRecord>>(result);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_content.TryGetValue((packId, type), out var bytes);
|
||||
return Task.FromResult<byte[]?>(bytes?.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
public sealed class InMemoryAuditRepository : IAuditRepository
|
||||
{
|
||||
private readonly ConcurrentBag<AuditRecord> _events = new();
|
||||
|
||||
public Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_events.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<AuditRecord> result = _events;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
result = result.Where(e => string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var ordered = result
|
||||
.OrderBy(e => e.OccurredAtUtc)
|
||||
.ThenBy(e => e.PackId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditRecord>>(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
public sealed class InMemoryLifecycleRepository : ILifecycleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, LifecycleRecord> _records = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_records[record.PackId] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_records.TryGetValue(packId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<LifecycleRecord> result = _records.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
return Task.FromResult<IReadOnlyList<LifecycleRecord>>(ordered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
public sealed class InMemoryMirrorRepository : IMirrorRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, MirrorSourceRecord> _records = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_records[record.Id] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<MirrorSourceRecord> result = _records.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MirrorSourceRecord>>(result.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
public Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_records.TryGetValue(id, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory repository for early development and tests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPackRepository : IPackRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PackRecord> _packs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte[]> _content = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte[]> _provenance = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
_packs[record.PackId] = record;
|
||||
_content[record.Digest] = content.ToArray();
|
||||
if (provenance is { Length: > 0 } && !string.IsNullOrWhiteSpace(record.ProvenanceDigest))
|
||||
{
|
||||
_provenance[record.ProvenanceDigest!] = provenance.ToArray();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_packs.TryGetValue(packId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<PackRecord> result = _packs.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
result = result.Where(p => string.Equals(p.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var ordered = result
|
||||
.OrderBy(p => p.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(p => p.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PackRecord>>(ordered);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_packs.TryGetValue(packId, out var record) && _content.TryGetValue(record.Digest, out var bytes))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(bytes.ToArray());
|
||||
}
|
||||
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_packs.TryGetValue(packId, out var record) && record.ProvenanceDigest is not null && _provenance.TryGetValue(record.ProvenanceDigest, out var bytes))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(bytes.ToArray());
|
||||
}
|
||||
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
|
||||
public sealed class InMemoryParityRepository : IParityRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ParityRecord> _parity = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_parity[record.PackId] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_parity.TryGetValue(packId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ParityRecord> result = _parity.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
return Task.FromResult<IReadOnlyList<ParityRecord>>(ordered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoAttestationRepository : IAttestationRepository
|
||||
{
|
||||
private readonly IMongoCollection<AttestationDocument> _index;
|
||||
private readonly IMongoCollection<AttestationBlob> _blobs;
|
||||
|
||||
public MongoAttestationRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
_index = database.GetCollection<AttestationDocument>(options.AttestationCollection ?? "packs_attestations");
|
||||
_blobs = database.GetCollection<AttestationBlob>(options.AttestationBlobsCollection ?? "packs_attestation_blobs");
|
||||
_index.Indexes.CreateOne(new CreateIndexModel<AttestationDocument>(Builders<AttestationDocument>.IndexKeys.Ascending(x => x.PackId).Ascending(x => x.Type), new CreateIndexOptions { Unique = true }));
|
||||
_blobs.Indexes.CreateOne(new CreateIndexModel<AttestationBlob>(Builders<AttestationBlob>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = AttestationDocument.From(record);
|
||||
await _index.ReplaceOneAsync(x => x.PackId == record.PackId && x.Type == record.Type, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var blob = new AttestationBlob { Digest = record.Digest, Content = content };
|
||||
await _blobs.ReplaceOneAsync(x => x.Digest == blob.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _index.Find(x => x.PackId == packId && x.Type == type).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToModel();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var docs = await _index.Find(x => x.PackId == packId).SortBy(x => x.Type).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return docs.Select(d => d.ToModel()).ToList();
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = await GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blob = await _blobs.Find(x => x.Digest == record.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return blob?.Content;
|
||||
}
|
||||
|
||||
private sealed class AttestationDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string PackId { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Type { get; set; } = default!;
|
||||
public string Digest { get; set; } = default!;
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public AttestationRecord ToModel() => new(PackId, TenantId, Type, Digest, CreatedAtUtc, Notes);
|
||||
public static AttestationDocument From(AttestationRecord record) => new()
|
||||
{
|
||||
PackId = record.PackId,
|
||||
TenantId = record.TenantId,
|
||||
Type = record.Type,
|
||||
Digest = record.Digest,
|
||||
CreatedAtUtc = record.CreatedAtUtc,
|
||||
Notes = record.Notes
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AttestationBlob
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string Digest { get; set; } = default!;
|
||||
public byte[] Content { get; set; } = default!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoAuditRepository : IAuditRepository
|
||||
{
|
||||
private readonly IMongoCollection<AuditDocument> _collection;
|
||||
|
||||
public MongoAuditRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
_collection = database.GetCollection<AuditDocument>(options.AuditCollection ?? "packs_audit_log");
|
||||
var indexKeys = Builders<AuditDocument>.IndexKeys
|
||||
.Ascending(x => x.TenantId)
|
||||
.Ascending(x => x.PackId)
|
||||
.Ascending(x => x.OccurredAtUtc);
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<AuditDocument>(indexKeys));
|
||||
}
|
||||
|
||||
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = AuditDocument.From(record);
|
||||
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = string.IsNullOrWhiteSpace(tenantId)
|
||||
? Builders<AuditDocument>.Filter.Empty
|
||||
: Builders<AuditDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.SortBy(x => x.OccurredAtUtc)
|
||||
.ThenBy(x => x.PackId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(d => d.ToModel()).ToList();
|
||||
}
|
||||
|
||||
private sealed class AuditDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string? PackId { get; set; }
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Event { get; set; } = default!;
|
||||
public DateTimeOffset OccurredAtUtc { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public AuditRecord ToModel() => new(PackId, TenantId, Event, OccurredAtUtc, Actor, Notes);
|
||||
|
||||
public static AuditDocument From(AuditRecord record) => new()
|
||||
{
|
||||
PackId = record.PackId,
|
||||
TenantId = record.TenantId,
|
||||
Event = record.Event,
|
||||
OccurredAtUtc = record.OccurredAtUtc,
|
||||
Actor = record.Actor,
|
||||
Notes = record.Notes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoLifecycleRepository : ILifecycleRepository
|
||||
{
|
||||
private readonly IMongoCollection<LifecycleDocument> _collection;
|
||||
|
||||
public MongoLifecycleRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
_collection = database.GetCollection<LifecycleDocument>(options.LifecycleCollection ?? "packs_lifecycle");
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<LifecycleDocument>(Builders<LifecycleDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = LifecycleDocument.From(record);
|
||||
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToModel();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = string.IsNullOrWhiteSpace(tenantId)
|
||||
? Builders<LifecycleDocument>.Filter.Empty
|
||||
: Builders<LifecycleDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.SortBy(x => x.PackId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(d => d.ToModel()).ToList();
|
||||
}
|
||||
|
||||
private sealed class LifecycleDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string PackId { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string State { get; set; } = default!;
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
public LifecycleRecord ToModel() => new(PackId, TenantId, State, Notes, UpdatedAtUtc);
|
||||
public static LifecycleDocument From(LifecycleRecord record) => new()
|
||||
{
|
||||
PackId = record.PackId,
|
||||
TenantId = record.TenantId,
|
||||
State = record.State,
|
||||
Notes = record.Notes,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoMirrorRepository : IMirrorRepository
|
||||
{
|
||||
private readonly IMongoCollection<MirrorDocument> _collection;
|
||||
|
||||
public MongoMirrorRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
_collection = database.GetCollection<MirrorDocument>(options.MirrorCollection ?? "packs_mirrors");
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<MirrorDocument>(Builders<MirrorDocument>.IndexKeys.Ascending(x => x.Id), new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = MirrorDocument.From(record);
|
||||
await _collection.ReplaceOneAsync(x => x.Id == record.Id, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = string.IsNullOrWhiteSpace(tenantId)
|
||||
? Builders<MirrorDocument>.Filter.Empty
|
||||
: Builders<MirrorDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
|
||||
var docs = await _collection.Find(filter).SortBy(x => x.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return docs.Select(d => d.ToModel()).ToList();
|
||||
}
|
||||
|
||||
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToModel();
|
||||
}
|
||||
|
||||
private sealed class MirrorDocument
|
||||
{
|
||||
public ObjectId InternalId { get; set; }
|
||||
public string Id { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Upstream { get; set; } = default!;
|
||||
public bool Enabled { get; set; }
|
||||
public string Status { get; set; } = default!;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset? LastSuccessfulSyncUtc { get; set; }
|
||||
|
||||
public MirrorSourceRecord ToModel() => new(Id, TenantId, new Uri(Upstream), Enabled, Status, UpdatedAtUtc, Notes, LastSuccessfulSyncUtc);
|
||||
public static MirrorDocument From(MirrorSourceRecord record) => new()
|
||||
{
|
||||
Id = record.Id,
|
||||
TenantId = record.TenantId,
|
||||
Upstream = record.UpstreamUri.ToString(),
|
||||
Enabled = record.Enabled,
|
||||
Status = record.Status,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
Notes = record.Notes,
|
||||
LastSuccessfulSyncUtc = record.LastSuccessfulSyncUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoPackRepository : IPackRepository
|
||||
{
|
||||
private readonly IMongoCollection<PackDocument> _packs;
|
||||
private readonly IMongoCollection<PackContentDocument> _contents;
|
||||
|
||||
public MongoPackRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
_packs = database.GetCollection<PackDocument>(options.PacksCollection);
|
||||
_contents = database.GetCollection<PackContentDocument>(options.BlobsCollection);
|
||||
|
||||
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
|
||||
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.Name).Ascending(x => x.Version)));
|
||||
_contents.Indexes.CreateOne(new CreateIndexModel<PackContentDocument>(Builders<PackContentDocument>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var packDoc = PackDocument.From(record);
|
||||
await _packs.ReplaceOneAsync(x => x.PackId == record.PackId, packDoc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var blob = new PackContentDocument
|
||||
{
|
||||
Digest = record.Digest,
|
||||
Content = content,
|
||||
ProvenanceDigest = record.ProvenanceDigest,
|
||||
Provenance = provenance
|
||||
};
|
||||
|
||||
await _contents.ReplaceOneAsync(x => x.Digest == record.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _packs.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToModel();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = string.IsNullOrWhiteSpace(tenantId)
|
||||
? Builders<PackDocument>.Filter.Empty
|
||||
: Builders<PackDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
|
||||
var docs = await _packs.Find(filter).SortBy(x => x.TenantId).ThenBy(x => x.Name).ThenBy(x => x.Version).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return docs.Select(d => d.ToModel()).ToArray();
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return blob?.Content;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null || string.IsNullOrWhiteSpace(pack.ProvenanceDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return blob?.Provenance;
|
||||
}
|
||||
|
||||
private sealed class PackDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string PackId { get; set; } = default!;
|
||||
public string Name { get; set; } = default!;
|
||||
public string Version { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Digest { get; set; } = default!;
|
||||
public string? Signature { get; set; }
|
||||
public string? ProvenanceUri { get; set; }
|
||||
public string? ProvenanceDigest { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public PackRecord ToModel() => new(PackId, Name, Version, TenantId, Digest, Signature, ProvenanceUri, ProvenanceDigest, CreatedAtUtc, Metadata);
|
||||
|
||||
public static PackDocument From(PackRecord model) => new()
|
||||
{
|
||||
PackId = model.PackId,
|
||||
Name = model.Name,
|
||||
Version = model.Version,
|
||||
TenantId = model.TenantId,
|
||||
Digest = model.Digest,
|
||||
Signature = model.Signature,
|
||||
ProvenanceUri = model.ProvenanceUri,
|
||||
ProvenanceDigest = model.ProvenanceDigest,
|
||||
CreatedAtUtc = model.CreatedAtUtc,
|
||||
Metadata = model.Metadata?.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class PackContentDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string Digest { get; set; } = default!;
|
||||
public byte[] Content { get; set; } = Array.Empty<byte>();
|
||||
public string? ProvenanceDigest { get; set; }
|
||||
public byte[]? Provenance { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
public sealed class MongoParityRepository : IParityRepository
|
||||
{
|
||||
private readonly IMongoCollection<ParityDocument> _collection;
|
||||
|
||||
public MongoParityRepository(IMongoDatabase database, MongoOptions options)
|
||||
{
|
||||
_collection = database.GetCollection<ParityDocument>(options.ParityCollection ?? "packs_parity_matrix");
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<ParityDocument>(Builders<ParityDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = ParityDocument.From(record);
|
||||
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToModel();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = string.IsNullOrWhiteSpace(tenantId)
|
||||
? Builders<ParityDocument>.Filter.Empty
|
||||
: Builders<ParityDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.SortBy(x => x.PackId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(d => d.ToModel()).ToList();
|
||||
}
|
||||
|
||||
private sealed class ParityDocument
|
||||
{
|
||||
public ObjectId Id { get; set; }
|
||||
public string PackId { get; set; } = default!;
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Status { get; set; } = default!;
|
||||
public string? Notes { get; set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
public ParityRecord ToModel() => new(PackId, TenantId, Status, Notes, UpdatedAtUtc);
|
||||
public static ParityDocument From(ParityRecord record) => new()
|
||||
{
|
||||
PackId = record.PackId,
|
||||
TenantId = record.TenantId,
|
||||
Status = record.Status,
|
||||
Notes = record.Notes,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures Mongo collections and indexes exist for packs, blobs, and parity matrix.
|
||||
/// </summary>
|
||||
public sealed class PacksMongoInitializer : IHostedService
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly MongoOptions _options;
|
||||
private readonly ILogger<PacksMongoInitializer> _logger;
|
||||
|
||||
public PacksMongoInitializer(IMongoDatabase database, MongoOptions options, ILogger<PacksMongoInitializer> logger)
|
||||
{
|
||||
_database = database ?? throw new ArgumentNullException(nameof(database));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsurePacksIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureBlobsIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureParityMatrixAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureLifecycleAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureAuditAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureAttestationsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureMirrorsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task EnsurePacksIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var packs = _database.GetCollection<BsonDocument>(_options.PacksCollection);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
|
||||
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var secondary = Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("name").Ascending("version");
|
||||
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(secondary), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureBlobsIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var blobs = _database.GetCollection<BsonDocument>(_options.BlobsCollection);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("digest");
|
||||
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureParityMatrixAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var parityName = _options.ParityCollection ?? "packs_parity_matrix";
|
||||
var parity = _database.GetCollection<BsonDocument>(parityName);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
|
||||
await parity.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Mongo collections ensured: {Packs}, {Blobs}, {Parity}", _options.PacksCollection, _options.BlobsCollection, parityName);
|
||||
}
|
||||
|
||||
private async Task EnsureLifecycleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lifecycleName = _options.LifecycleCollection ?? "packs_lifecycle";
|
||||
var lifecycle = _database.GetCollection<BsonDocument>(lifecycleName);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
|
||||
await lifecycle.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Mongo lifecycle collection ensured: {Lifecycle}", lifecycleName);
|
||||
}
|
||||
|
||||
private async Task EnsureAuditAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var auditName = _options.AuditCollection ?? "packs_audit_log";
|
||||
var audit = _database.GetCollection<BsonDocument>(auditName);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("packId")
|
||||
.Ascending("occurredAtUtc");
|
||||
await audit.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Mongo audit collection ensured: {Audit}", auditName);
|
||||
}
|
||||
|
||||
private async Task EnsureAttestationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var attestName = _options.AttestationCollection ?? "packs_attestations";
|
||||
var attest = _database.GetCollection<BsonDocument>(attestName);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId").Ascending("type");
|
||||
await attest.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var blobsName = _options.AttestationBlobsCollection ?? "packs_attestation_blobs";
|
||||
var blobs = _database.GetCollection<BsonDocument>(blobsName);
|
||||
var blobIndex = Builders<BsonDocument>.IndexKeys.Ascending("digest");
|
||||
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(blobIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Mongo attestation collections ensured: {Attest} / {AttestBlobs}", attestName, blobsName);
|
||||
}
|
||||
|
||||
private async Task EnsureMirrorsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var mirrorName = _options.MirrorCollection ?? "packs_mirrors";
|
||||
var mirrors = _database.GetCollection<BsonDocument>(mirrorName);
|
||||
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("id");
|
||||
await mirrors.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Mongo mirror collection ensured: {Mirror}", mirrorName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
|
||||
public sealed class MongoOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
public string Database { get; set; } = "packs_registry";
|
||||
public string PacksCollection { get; set; } = "packs_index";
|
||||
public string BlobsCollection { get; set; } = "packs_blobs";
|
||||
public string? ParityCollection { get; set; } = "packs_parity_matrix";
|
||||
public string? LifecycleCollection { get; set; } = "packs_lifecycle";
|
||||
public string? AuditCollection { get; set; } = "packs_audit_log";
|
||||
public string? AttestationCollection { get; set; } = "packs_attestations";
|
||||
public string? AttestationBlobsCollection { get; set; } = "packs_attestation_blobs";
|
||||
public string? MirrorCollection { get; set; } = "packs_mirrors";
|
||||
}
|
||||
@@ -3,14 +3,22 @@
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies signatures over the digest string using an RSA public key (PEM, PKCS#8 or PKCS#1), SHA-256.
|
||||
/// Signature input is expected to be base64 of the raw RSA signature over UTF-8 digest text (e.g. "sha256:abcd...").
|
||||
/// </summary>
|
||||
public sealed class RsaSignatureVerifier : IPackSignatureVerifier
|
||||
{
|
||||
private readonly RSA _rsa;
|
||||
|
||||
public RsaSignatureVerifier(string publicKeyPem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
throw new ArgumentException("Public key PEM is required for RSA verification.", nameof(publicKeyPem));
|
||||
}
|
||||
|
||||
_rsa = RSA.Create();
|
||||
_rsa.ImportFromPem(publicKeyPem);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
byte[] sigBytes;
|
||||
try
|
||||
{
|
||||
sigBytes = Convert.FromBase64String(signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var data = Encoding.UTF8.GetBytes(digest);
|
||||
var valid = _rsa.VerifyData(data, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return Task.FromResult(valid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts either no signature or a signature that matches the computed SHA-256 digest (hex or base64 of digest string).
|
||||
/// Replace with real signature verification when keys/attestations are available.
|
||||
/// </summary>
|
||||
public sealed class SimpleSignatureVerifier : IPackSignatureVerifier
|
||||
{
|
||||
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(content)).ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
return Task.FromResult(string.Equals(computed, digest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (string.Equals(signature, computed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(signature));
|
||||
if (string.Equals(decoded, computed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore decode errors
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.IO.Compression;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class ExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Offline_seed_includes_metadata_and_content_when_requested()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var packRepo = new InMemoryPackRepository();
|
||||
var parityRepo = new InMemoryParityRepository();
|
||||
var lifecycleRepo = new InMemoryLifecycleRepository();
|
||||
var auditRepo = new InMemoryAuditRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
|
||||
var packService = new PackService(packRepo, verifier, auditRepo, null, TimeProvider.System);
|
||||
var parityService = new ParityService(parityRepo, packRepo, auditRepo, TimeProvider.System);
|
||||
var lifecycleService = new LifecycleService(lifecycleRepo, packRepo, auditRepo, TimeProvider.System);
|
||||
var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, auditRepo, TimeProvider.System);
|
||||
|
||||
var content = System.Text.Encoding.UTF8.GetBytes("export-pack");
|
||||
var provenance = System.Text.Encoding.UTF8.GetBytes("{\"p\":1}");
|
||||
var record = await packService.UploadAsync("demo", "1.2.3", "tenant-1", content, null, null, provenance, null, ct);
|
||||
await parityService.SetStatusAsync(record.PackId, record.TenantId, "ready", "seed", ct);
|
||||
await lifecycleService.SetStateAsync(record.PackId, record.TenantId, "promoted", "seed", ct);
|
||||
|
||||
var archiveStream = await exportService.ExportOfflineSeedAsync(record.TenantId, includeContent: true, includeProvenance: true, cancellationToken: ct);
|
||||
using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read);
|
||||
|
||||
Assert.NotNull(archive.GetEntry("packs.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("parity.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("lifecycle.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry("audit.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin"));
|
||||
Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class FilePackRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upsert_and_List_round_trip()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempPath);
|
||||
try
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new FilePackRepository(tempPath);
|
||||
|
||||
var record = new PackRecord(
|
||||
PackId: "demo@1.0.0",
|
||||
Name: "demo",
|
||||
Version: "1.0.0",
|
||||
TenantId: "t1",
|
||||
Digest: "sha256:abc",
|
||||
Signature: null,
|
||||
ProvenanceUri: null,
|
||||
ProvenanceDigest: null,
|
||||
CreatedAtUtc: DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
|
||||
Metadata: new Dictionary<string, string> { ["lang"] = "csharp" });
|
||||
|
||||
await repo.UpsertAsync(record, new byte[] { 1, 2, 3 }, null, ct);
|
||||
|
||||
var listed = await repo.ListAsync("t1", ct);
|
||||
Assert.Single(listed);
|
||||
Assert.Equal(record.PackId, listed[0].PackId);
|
||||
|
||||
var fetched = await repo.GetAsync("demo@1.0.0", ct);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(record.Digest, fetched!.Digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class PackServiceTests
|
||||
{
|
||||
private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content");
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_persists_pack_with_digest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
|
||||
|
||||
var record = await service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: null,
|
||||
provenanceUri: "https://example/manifest.json",
|
||||
provenanceContent: null,
|
||||
metadata: new Dictionary<string, string> { ["lang"] = "csharp" },
|
||||
cancellationToken: ct);
|
||||
|
||||
Assert.Equal("demo-pack@1.0.0", record.PackId);
|
||||
Assert.NotNull(record.Digest);
|
||||
|
||||
var listed = await service.ListAsync("tenant-1", ct);
|
||||
Assert.Single(listed);
|
||||
Assert.Equal(record.PackId, listed[0].PackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_when_digest_mismatch()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var verifier = new AlwaysFailSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: "bogus",
|
||||
provenanceUri: null,
|
||||
provenanceContent: null,
|
||||
metadata: null,
|
||||
cancellationToken: ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_signature_updates_record_and_audits()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var repo = new InMemoryPackRepository();
|
||||
var audit = new InMemoryAuditRepository();
|
||||
var verifier = new SimpleSignatureVerifier();
|
||||
var service = new PackService(repo, verifier, audit, null, TimeProvider.System);
|
||||
|
||||
var record = await service.UploadAsync(
|
||||
name: "demo-pack",
|
||||
version: "1.0.0",
|
||||
tenantId: "tenant-1",
|
||||
content: SampleContent,
|
||||
signature: null,
|
||||
provenanceUri: null,
|
||||
provenanceContent: null,
|
||||
metadata: null,
|
||||
cancellationToken: ct);
|
||||
|
||||
var digest = record.Digest;
|
||||
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(digest));
|
||||
|
||||
var rotated = await service.RotateSignatureAsync(record.PackId, record.TenantId, newSignature, cancellationToken: ct);
|
||||
|
||||
Assert.Equal(newSignature, rotated.Signature);
|
||||
|
||||
var auditEvents = await audit.ListAsync(record.TenantId, ct);
|
||||
Assert.Contains(auditEvents, a => a.Event == "signature.rotated" && a.PackId == record.PackId);
|
||||
}
|
||||
|
||||
private sealed class AlwaysFailSignatureVerifier : StellaOps.PacksRegistry.Core.Contracts.IPackSignatureVerifier
|
||||
{
|
||||
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class PacksApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public PacksApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IPackRepository>();
|
||||
services.RemoveAll<IParityRepository>();
|
||||
services.RemoveAll<ILifecycleRepository>();
|
||||
services.RemoveAll<IAuditRepository>();
|
||||
services.AddSingleton<IPackRepository, InMemoryPackRepository>();
|
||||
services.AddSingleton<IParityRepository, InMemoryParityRepository>();
|
||||
services.AddSingleton<ILifecycleRepository, InMemoryLifecycleRepository>();
|
||||
services.AddSingleton<IAuditRepository, InMemoryAuditRepository>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.RemoveAll<PackService>();
|
||||
services.RemoveAll<ParityService>();
|
||||
services.RemoveAll<LifecycleService>();
|
||||
services.RemoveAll<ExportService>();
|
||||
services.AddSingleton<PackService>();
|
||||
services.AddSingleton<ParityService>();
|
||||
services.AddSingleton<LifecycleService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_and_download_round_trip()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "t1");
|
||||
var auth = _factory.Services.GetRequiredService<StellaOps.PacksRegistry.WebService.Options.AuthOptions>();
|
||||
if (!string.IsNullOrWhiteSpace(auth.ApiKey))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey);
|
||||
}
|
||||
|
||||
var payload = new PackUploadRequest
|
||||
{
|
||||
Name = "demo",
|
||||
Version = "1.0.0",
|
||||
TenantId = "t1",
|
||||
Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")),
|
||||
ProvenanceUri = "https://example/provenance.json",
|
||||
ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"provenance\":true}"))
|
||||
};
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/packs")
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(message, ct);
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"Upload failed with {response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("demo", created!.Name);
|
||||
Assert.Equal("1.0.0", created.Version);
|
||||
|
||||
var get = await client.GetAsync($"/api/v1/packs/{created.PackId}", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
|
||||
var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
|
||||
var bytes = await content.Content.ReadAsByteArrayAsync(ct);
|
||||
Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(bytes));
|
||||
Assert.True(content.Headers.Contains("X-Content-Digest"));
|
||||
|
||||
var prov = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, prov.StatusCode);
|
||||
var provBytes = await prov.Content.ReadAsByteArrayAsync(ct);
|
||||
Assert.Contains("provenance", System.Text.Encoding.UTF8.GetString(provBytes));
|
||||
Assert.True(prov.Headers.Contains("X-Provenance-Digest"));
|
||||
|
||||
var manifest = await client.GetFromJsonAsync<PackManifestResponse>($"/api/v1/packs/{created.PackId}/manifest", ct);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(created.PackId, manifest!.PackId);
|
||||
Assert.True(manifest.ContentLength > 0);
|
||||
Assert.True(manifest.ProvenanceLength > 0);
|
||||
|
||||
// parity status
|
||||
var parityResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/parity", new ParityRequest { Status = "ready", Notes = "tests" }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode);
|
||||
|
||||
var parity = await client.GetFromJsonAsync<ParityResponse>($"/api/v1/packs/{created.PackId}/parity", ct);
|
||||
Assert.NotNull(parity);
|
||||
Assert.Equal("ready", parity!.Status);
|
||||
|
||||
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(created.Digest));
|
||||
var rotationResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/signature", new RotateSignatureRequest { Signature = newSignature }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, rotationResponse.StatusCode);
|
||||
var rotated = await rotationResponse.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
|
||||
Assert.Equal(newSignature, rotated!.Signature);
|
||||
|
||||
var offlineSeed = await client.PostAsJsonAsync("/api/v1/export/offline-seed", new OfflineSeedRequest { TenantId = "t1", IncludeContent = true, IncludeProvenance = true }, ct);
|
||||
Assert.Equal(HttpStatusCode.OK, offlineSeed.StatusCode);
|
||||
var bytesZip = await offlineSeed.Content.ReadAsByteArrayAsync(ct);
|
||||
using var archive = new ZipArchive(new MemoryStream(bytesZip));
|
||||
Assert.NotNull(archive.GetEntry("packs.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"content/{created.PackId}.bin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class RsaSignatureVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Verify_succeeds_when_signature_matches_digest()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicPem = ExportPublicPem(rsa);
|
||||
|
||||
const string digest = "sha256:deadbeef";
|
||||
var sig = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(digest), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
var verifier = new RsaSignatureVerifier(publicPem);
|
||||
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
|
||||
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_on_invalid_signature()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicPem = ExportPublicPem(rsa);
|
||||
const string digest = "sha256:deadbeef";
|
||||
var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("bogus"));
|
||||
|
||||
var verifier = new RsaSignatureVerifier(publicPem);
|
||||
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
private static string ExportPublicPem(RSA rsa)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
builder.AppendLine(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo(), Base64FormattingOptions.InsertLineBreaks));
|
||||
builder.AppendLine("-----END PUBLIC KEY-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
|
||||
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
|
||||
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Using Include="Xunit"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<?xml version="1.0"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.WebService\StellaOps.PacksRegistry.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record AttestationResponse(string PackId, string Type, string Digest, DateTimeOffset CreatedAtUtc, string? Notes)
|
||||
{
|
||||
public static AttestationResponse From(AttestationRecord record) => new(record.PackId, record.Type, record.Digest, record.CreatedAtUtc, record.Notes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed class AttestationUploadRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record ComplianceSummaryResponse(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks)
|
||||
{
|
||||
public static ComplianceSummaryResponse From(ComplianceSummary summary) => new(summary.TotalPacks, summary.UnsignedPacks, summary.PromotedPacks, summary.DeprecatedPacks, summary.ParityReadyPacks);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user