work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record LifecycleRequest
|
||||
{
|
||||
[Required]
|
||||
public string? State { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record LifecycleResponse(string PackId, string TenantId, string State, string? Notes, DateTimeOffset UpdatedAtUtc)
|
||||
{
|
||||
public static LifecycleResponse From(LifecycleRecord record) => new(record.PackId, record.TenantId, record.State, record.Notes, record.UpdatedAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed class MirrorRequest
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Upstream { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record MirrorResponse(string Id, string TenantId, string Upstream, bool Enabled, string Status, DateTimeOffset UpdatedAtUtc, DateTimeOffset? LastSuccessfulSyncUtc, string? Notes)
|
||||
{
|
||||
public static MirrorResponse From(MirrorSourceRecord record) => new(record.Id, record.TenantId, record.UpstreamUri.ToString(), record.Enabled, record.Status, record.UpdatedAtUtc, record.LastSuccessfulSyncUtc, record.Notes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed class MirrorSyncRequest
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed class OfflineSeedRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public bool IncludeContent { get; set; }
|
||||
public bool IncludeProvenance { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record PackManifestResponse(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
long ContentLength,
|
||||
string? ProvenanceDigest,
|
||||
long? ProvenanceLength,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
@@ -0,0 +1,18 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record PackResponse(
|
||||
string PackId,
|
||||
string Name,
|
||||
string Version,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
string? Signature,
|
||||
string? ProvenanceUri,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
IReadOnlyDictionary<string, string>? Metadata)
|
||||
{
|
||||
public static PackResponse From(PackRecord record) =>
|
||||
new(record.PackId, record.Name, record.Version, record.TenantId, record.Digest, record.Signature, record.ProvenanceUri, record.CreatedAtUtc, record.Metadata);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record PackUploadRequest
|
||||
{
|
||||
[Required]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[Required]
|
||||
public string? Version { get; init; }
|
||||
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
[Required]
|
||||
public string? Content { get; init; } // base64 encoded
|
||||
|
||||
public string? Signature { get; init; }
|
||||
|
||||
public string? ProvenanceUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional provenance manifest content (base64). Stored and downloadable when present.
|
||||
/// </summary>
|
||||
public string? ProvenanceContent { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record ParityRequest
|
||||
{
|
||||
[Required]
|
||||
public string? Status { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record ParityResponse(string PackId, string TenantId, string Status, string? Notes, DateTimeOffset UpdatedAtUtc)
|
||||
{
|
||||
public static ParityResponse From(ParityRecord record) => new(record.PackId, record.TenantId, record.Status, record.Notes, record.UpdatedAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed class RotateSignatureRequest
|
||||
{
|
||||
public string? Signature { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PEM-encoded public key to validate the new signature; falls back to configured verifier if not provided.
|
||||
/// </summary>
|
||||
public string? PublicKeyPem { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "StellaOps Packs Registry",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/packs/{packId}/manifest": {
|
||||
"get": {
|
||||
"summary": "Fetch pack manifest",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Manifest",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/PackManifest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Unauthorized" },
|
||||
"403": { "description": "Forbidden" },
|
||||
"404": { "description": "Not Found" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"PackManifest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"packId": { "type": "string" },
|
||||
"tenantId": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"contentLength": { "type": "integer", "format": "int64" },
|
||||
"provenanceDigest": { "type": "string", "nullable": true },
|
||||
"provenanceLength": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"createdAtUtc": { "type": "string", "format": "date-time" },
|
||||
"metadata": { "type": "object", "additionalProperties": { "type": "string" }, "nullable": true }
|
||||
},
|
||||
"required": ["packId", "tenantId", "digest", "contentLength", "createdAtUtc"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "StellaOps Packs Registry",
|
||||
"version": "0.3.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/packs": {
|
||||
"get": {
|
||||
"summary": "List packs",
|
||||
"parameters": [
|
||||
{ "name": "tenant", "in": "query", "schema": { "type": "string" }, "description": "Filter to tenant; required when allowlists are configured." },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Upload pack",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PackUpload" } } } },
|
||||
"parameters": [
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}": {
|
||||
"get": {
|
||||
"summary": "Get pack",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/content": {
|
||||
"get": {
|
||||
"summary": "Download pack content",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Content" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/provenance": {
|
||||
"get": {
|
||||
"summary": "Download provenance",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Provenance" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/manifest": {
|
||||
"get": {
|
||||
"summary": "Get manifest",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Manifest" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/parity": {
|
||||
"get": {
|
||||
"summary": "Get parity status",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Parity" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Set parity status",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ParityRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/lifecycle": {
|
||||
"get": {
|
||||
"summary": "Get lifecycle state",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Lifecycle" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Set lifecycle state",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LifecycleRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/signature": {
|
||||
"post": {
|
||||
"summary": "Rotate pack signature",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateSignatureRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Rotated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/attestations": {
|
||||
"get": {
|
||||
"summary": "List pack attestations",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Attestations" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Upload attestation",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AttestationUpload" } } } },
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/packs/{packId}/attestations/{type}": {
|
||||
"get": {
|
||||
"summary": "Download attestation",
|
||||
"parameters": [
|
||||
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "type", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Attestation" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/export/offline-seed": {
|
||||
"post": {
|
||||
"summary": "Export offline seed archive",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfflineSeedRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Archive" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/mirrors": {
|
||||
"get": {
|
||||
"summary": "List mirror sources",
|
||||
"parameters": [
|
||||
{ "name": "tenant", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Register or update mirror source",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/mirrors/{id}/sync": {
|
||||
"post": {
|
||||
"summary": "Mark mirror sync status",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorSyncRequest" } } } },
|
||||
"parameters": [
|
||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/compliance/summary": {
|
||||
"get": {
|
||||
"summary": "Compliance summary",
|
||||
"parameters": [
|
||||
{ "name": "tenant", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
|
||||
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Summary" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"PackUpload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"tenantId": { "type": "string" },
|
||||
"content": { "type": "string", "format": "byte" },
|
||||
"signature": { "type": "string" },
|
||||
"provenanceUri": { "type": "string" },
|
||||
"provenanceContent": { "type": "string", "format": "byte" },
|
||||
"metadata": { "type": "object", "additionalProperties": { "type": "string" } }
|
||||
},
|
||||
"required": ["name", "version", "content"]
|
||||
},
|
||||
"ParityRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"required": ["status"]
|
||||
},
|
||||
"LifecycleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": { "type": "string", "enum": ["promoted", "deprecated", "draft"] },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"required": ["state"]
|
||||
},
|
||||
"RotateSignatureRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"signature": { "type": "string" },
|
||||
"publicKeyPem": { "type": "string" }
|
||||
},
|
||||
"required": ["signature"]
|
||||
},
|
||||
"OfflineSeedRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tenantId": { "type": "string" },
|
||||
"includeContent": { "type": "boolean" },
|
||||
"includeProvenance": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"AttestationUpload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"content": { "type": "string", "format": "byte" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "content"]
|
||||
},
|
||||
"MirrorRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"upstream": { "type": "string", "format": "uri" },
|
||||
"enabled": { "type": "boolean" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"required": ["id", "upstream"]
|
||||
},
|
||||
"MirrorSyncRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Options;
|
||||
|
||||
public sealed class AuthOptions
|
||||
{
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public string[] AllowedTenants { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.PacksRegistry.WebService.Options;
|
||||
|
||||
public sealed class VerificationOptions
|
||||
{
|
||||
public string? PublicKeyPem { get; set; }
|
||||
}
|
||||
@@ -1,41 +1,770 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
{
|
||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
||||
new WeatherForecast
|
||||
(
|
||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
Random.Shared.Next(-20, 55),
|
||||
summaries[Random.Shared.Next(summaries.Length)]
|
||||
))
|
||||
.ToArray();
|
||||
return forecast;
|
||||
})
|
||||
.WithName("GetWeatherForecast");
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
using StellaOps.PacksRegistry.WebService;
|
||||
using StellaOps.PacksRegistry.WebService.Contracts;
|
||||
using StellaOps.PacksRegistry.WebService.Options;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using MongoDB.Driver;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
var dataDir = builder.Configuration.GetValue<string>("PacksRegistry:DataDir");
|
||||
var mongoOptions = builder.Configuration.GetSection("PacksRegistry:Mongo").Get<MongoOptions>() ?? new MongoOptions();
|
||||
mongoOptions.ConnectionString ??= builder.Configuration.GetConnectionString("packs-registry");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mongoOptions.ConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(mongoOptions);
|
||||
builder.Services.AddSingleton<IMongoClient>(_ => new MongoClient(mongoOptions.ConnectionString));
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IMongoClient>().GetDatabase(mongoOptions.Database));
|
||||
builder.Services.AddSingleton<IPackRepository, MongoPackRepository>();
|
||||
builder.Services.AddSingleton<IParityRepository, MongoParityRepository>();
|
||||
builder.Services.AddSingleton<ILifecycleRepository, MongoLifecycleRepository>();
|
||||
builder.Services.AddSingleton<IAuditRepository, MongoAuditRepository>();
|
||||
builder.Services.AddSingleton<IAttestationRepository, MongoAttestationRepository>();
|
||||
builder.Services.AddSingleton<IMirrorRepository, MongoMirrorRepository>();
|
||||
builder.Services.AddHostedService<PacksMongoInitializer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IPackRepository>(_ => new FilePackRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IParityRepository>(_ => new FileParityRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<ILifecycleRepository>(_ => new FileLifecycleRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IAuditRepository>(_ => new FileAuditRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IAttestationRepository>(_ => new FileAttestationRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IMirrorRepository>(_ => new FileMirrorRepository(dataDir ?? "data/packs"));
|
||||
}
|
||||
|
||||
var verificationSection = builder.Configuration.GetSection("PacksRegistry:Verification");
|
||||
builder.Services.Configure<VerificationOptions>(verificationSection);
|
||||
var publicKeyPem = verificationSection.GetValue<string>("PublicKeyPem");
|
||||
if (!string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
builder.Services.AddSingleton<IPackSignatureVerifier>(_ => new RsaSignatureVerifier(publicKeyPem));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IPackSignatureVerifier, SimpleSignatureVerifier>();
|
||||
}
|
||||
|
||||
var authOptions = builder.Configuration.GetSection("PacksRegistry:Auth").Get<AuthOptions>() ?? new AuthOptions();
|
||||
builder.Services.AddSingleton(authOptions);
|
||||
var policyOptions = builder.Configuration.GetSection("PacksRegistry:Policy").Get<PackPolicyOptions>() ?? new PackPolicyOptions();
|
||||
builder.Services.AddSingleton(policyOptions);
|
||||
builder.Services.AddSingleton<PackService>();
|
||||
builder.Services.AddSingleton<ParityService>();
|
||||
builder.Services.AddSingleton<LifecycleService>();
|
||||
builder.Services.AddSingleton<AttestationService>();
|
||||
builder.Services.AddSingleton<MirrorService>();
|
||||
builder.Services.AddSingleton<ComplianceService>();
|
||||
builder.Services.AddSingleton<ExportService>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
// Serve static OpenAPI stubs for packs APIs (until unified spec is generated)
|
||||
var openApiDir = Path.Combine(app.Environment.ContentRootPath, "OpenApi");
|
||||
if (Directory.Exists(openApiDir))
|
||||
{
|
||||
var provider = new PhysicalFileProvider(openApiDir);
|
||||
app.MapGet("/openapi/packs.json", () =>
|
||||
{
|
||||
var file = provider.GetFileInfo("packs.openapi.json");
|
||||
return file.Exists
|
||||
? Results.File(file.CreateReadStream(), "application/json")
|
||||
: Results.NotFound();
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
app.MapGet("/openapi/pack-manifest.json", () =>
|
||||
{
|
||||
var file = provider.GetFileInfo("pack-manifest.openapi.json");
|
||||
return file.Exists
|
||||
? Results.File(file.CreateReadStream(), "application/json")
|
||||
: Results.NotFound();
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
}
|
||||
|
||||
app.MapPost("/api/v1/packs", async (PackUploadRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header or tenantId is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (request.Content == null || request.Content.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "content_missing", message = "Content (base64) is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var contentBytes = Convert.FromBase64String(request.Content);
|
||||
byte[]? provenanceBytes = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.ProvenanceContent))
|
||||
{
|
||||
provenanceBytes = Convert.FromBase64String(request.ProvenanceContent);
|
||||
}
|
||||
var record = await service.UploadAsync(
|
||||
name: request.Name ?? string.Empty,
|
||||
version: request.Version ?? string.Empty,
|
||||
tenantId: tenant,
|
||||
content: contentBytes,
|
||||
signature: request.Signature,
|
||||
provenanceUri: request.ProvenanceUri,
|
||||
provenanceContent: provenanceBytes,
|
||||
metadata: request.Metadata,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v1/packs/{record.PackId}", PackResponse.From(record));
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return Results.BadRequest(new { error = "content_base64_invalid", message = "Content must be valid base64." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "upload_failed", message = ex.Message });
|
||||
}
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
app.MapGet("/api/v1/packs", async (string? tenant, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
|
||||
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "tenant query parameter or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
var packs = await service.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(packs.Select(PackResponse.From));
|
||||
})
|
||||
.Produces<IEnumerable<PackResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
return Results.Ok(PackResponse.From(record));
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/content", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
context.Response.Headers["X-Content-Digest"] = record.Digest;
|
||||
return Results.File(content, "application/octet-stream", fileDownloadName: packId + ".bin");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/provenance", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
var content = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.ProvenanceDigest))
|
||||
{
|
||||
context.Response.Headers["X-Provenance-Digest"] = record.ProvenanceDigest;
|
||||
}
|
||||
|
||||
return Results.File(content, "application/json", fileDownloadName: packId + "-provenance.json");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/manifest", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
var provenance = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var manifest = new PackManifestResponse(
|
||||
record.PackId,
|
||||
record.TenantId,
|
||||
record.Digest,
|
||||
content?.LongLength ?? 0,
|
||||
record.ProvenanceDigest,
|
||||
provenance?.LongLength,
|
||||
record.CreatedAtUtc,
|
||||
record.Metadata);
|
||||
|
||||
return Results.Ok(manifest);
|
||||
})
|
||||
.Produces<PackManifestResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapPost("/api/v1/packs/{packId}/signature", async (string packId, RotateSignatureRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Signature))
|
||||
{
|
||||
return Results.BadRequest(new { error = "signature_missing", message = "signature is required." });
|
||||
}
|
||||
|
||||
IPackSignatureVerifier? overrideVerifier = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.PublicKeyPem))
|
||||
{
|
||||
overrideVerifier = new RsaSignatureVerifier(request.PublicKeyPem!);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await service.RotateSignatureAsync(packId, tenantHeader, request.Signature!, overrideVerifier, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(PackResponse.From(updated));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "signature_rotation_failed", message = ex.Message });
|
||||
}
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapPost("/api/v1/packs/{packId}/attestations", async (string packId, AttestationUploadRequest request, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Type) || string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
return Results.BadRequest(new { error = "attestation_missing", message = "type and content are required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(request.Content);
|
||||
var record = await attestationService.UploadAsync(packId, tenantHeader, request.Type!, bytes, request.Notes, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/packs/{packId}/attestations/{record.Type}", AttestationResponse.From(record));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "attestation_failed", message = ex.Message });
|
||||
}
|
||||
})
|
||||
.Produces<AttestationResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/attestations", async (string packId, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var records = await attestationService.ListAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) && !records.All(r => string.Equals(r.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return Results.Ok(records.Select(AttestationResponse.From));
|
||||
})
|
||||
.Produces<IEnumerable<AttestationResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/attestations/{type}", async (string packId, string type, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await attestationService.GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var content = await attestationService.GetContentAsync(packId, type, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
context.Response.Headers["X-Attestation-Digest"] = record.Digest;
|
||||
return Results.File(content, "application/octet-stream", fileDownloadName: $"{packId}-{type}-attestation.bin");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/parity", async (string packId, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var parity = await parityService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (parity is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(parity.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(parity.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
return Results.Ok(ParityResponse.From(parity));
|
||||
})
|
||||
.Produces<ParityResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var record = await lifecycleService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
|
||||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return tenantResult ?? Results.Forbid();
|
||||
}
|
||||
|
||||
return Results.Ok(LifecycleResponse.From(record));
|
||||
})
|
||||
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapPost("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleRequest request, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.State))
|
||||
{
|
||||
return Results.BadRequest(new { error = "state_missing", message = "state is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = await lifecycleService.SetStateAsync(packId, tenant, request.State!, request.Notes, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(LifecycleResponse.From(record));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "lifecycle_failed", message = ex.Message });
|
||||
}
|
||||
})
|
||||
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapPost("/api/v1/packs/{packId}/parity", async (string packId, ParityRequest request, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
return Results.BadRequest(new { error = "status_missing", message = "status is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = await parityService.SetStatusAsync(packId, tenant, request.Status!, request.Notes, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(ParityResponse.From(record));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "parity_failed", message = ex.Message });
|
||||
}
|
||||
})
|
||||
.Produces<ParityResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapPost("/api/v1/export/offline-seed", async (OfflineSeedRequest request, ExportService exportService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
|
||||
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "tenantId or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenant) && !IsTenantAllowed(tenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
var archive = await exportService.ExportOfflineSeedAsync(tenant, request.IncludeContent, request.IncludeProvenance, cancellationToken).ConfigureAwait(false);
|
||||
return Results.File(archive, "application/zip", fileDownloadName: "packs-offline-seed.zip");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.MapPost("/api/v1/mirrors", async (MirrorRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Upstream))
|
||||
{
|
||||
return Results.BadRequest(new { error = "mirror_missing", message = "id and upstream are required." });
|
||||
}
|
||||
|
||||
var record = await mirrorService.UpsertAsync(request.Id!, tenantHeader, new Uri(request.Upstream!), request.Enabled, request.Notes, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/mirrors/{record.Id}", MirrorResponse.From(record));
|
||||
})
|
||||
.Produces<MirrorResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.MapGet("/api/v1/mirrors", async (string? tenant, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
var mirrors = await mirrorService.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(mirrors.Select(MirrorResponse.From));
|
||||
})
|
||||
.Produces<IEnumerable<MirrorResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.MapPost("/api/v1/mirrors/{id}/sync", async (string id, MirrorSyncRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
|
||||
}
|
||||
|
||||
var updated = await mirrorService.MarkSyncAsync(id, tenantHeader, request.Status ?? "unknown", request.Notes, cancellationToken).ConfigureAwait(false);
|
||||
if (updated is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MirrorResponse.From(updated));
|
||||
})
|
||||
.Produces<MirrorResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.MapGet("/api/v1/compliance/summary", async (string? tenant, ComplianceService complianceService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsAuthorized(context, auth, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
|
||||
{
|
||||
return tenantResult;
|
||||
}
|
||||
|
||||
var summary = await complianceService.SummarizeAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(summary);
|
||||
})
|
||||
.Produces<ComplianceSummary>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAuthorized(HttpContext context, AuthOptions auth, out IResult result)
|
||||
{
|
||||
result = Results.Empty;
|
||||
if (string.IsNullOrWhiteSpace(auth.ApiKey))
|
||||
{
|
||||
return true; // auth disabled
|
||||
}
|
||||
|
||||
var provided = context.Request.Headers["X-API-Key"].ToString();
|
||||
if (string.Equals(provided, auth.ApiKey, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
result = Results.Unauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool IsTenantAllowed(string tenant, AuthOptions auth, out IResult? result)
|
||||
{
|
||||
result = null;
|
||||
if (auth.AllowedTenants is { Length: > 0 } && !auth.AllowedTenants.Any(t => string.Equals(t, tenant, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
result = Results.Forbid();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5151",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7136;http://localhost:5151",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user