consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
30
src/JobEngine/StellaOps.PacksRegistry/AGENTS.md
Normal file
30
src/JobEngine/StellaOps.PacksRegistry/AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Packs Registry Service — Agent Charter
|
||||
|
||||
## Mission
|
||||
Host signed Task Pack bundles with provenance and RBAC for Epic 12. Ensure packs are verifiable, auditable, and distributed safely, respecting the imposed rule to propagate similar safeguards elsewhere.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain packs index, signature verification, provenance metadata, tenant visibility, and registry APIs.
|
||||
- Integrate with CLI, Task Runner, JobEngine, Authority, Export Center, and DevOps tooling.
|
||||
- Guarantee deterministic digest computations, immutable history, and secure storage of pack artefacts.
|
||||
|
||||
## Module Layout
|
||||
- `StellaOps.PacksRegistry.Core/` — pack catalogue models, validation, lifecycle orchestration.
|
||||
- `StellaOps.PacksRegistry.Infrastructure/` — storage providers, signature verification hooks, provenance stores.
|
||||
- `StellaOps.PacksRegistry.WebService/` — registry APIs and RBAC enforcement.
|
||||
- `StellaOps.PacksRegistry.Worker/` — background reconciliation, mirroring, and rotation jobs.
|
||||
- `StellaOps.PacksRegistry.Tests/` — unit tests validating core/infrastructure logic.
|
||||
- `StellaOps.PacksRegistry.sln` — module solution.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 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 file-backed audit logs).
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.PacksRegistry.Core Agent Charter
|
||||
|
||||
## Mission
|
||||
Define pack registry domain models and validation for pack catalogs and versions.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep behavior deterministic (stable ordering, timestamps, hashes).
|
||||
- Add or update tests for pack registry invariants and validation.
|
||||
@@ -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,72 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
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,56 @@
|
||||
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;
|
||||
private readonly IAttestationRepository _attestationRepository;
|
||||
|
||||
public ComplianceService(
|
||||
IPackRepository packRepository,
|
||||
IParityRepository parityRepository,
|
||||
ILifecycleRepository lifecycleRepository,
|
||||
IAttestationRepository attestationRepository)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
_attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository));
|
||||
}
|
||||
|
||||
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));
|
||||
var attested = 0;
|
||||
foreach (var pack in packs)
|
||||
{
|
||||
var attestations = await _attestationRepository.ListAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
if (attestations.Count > 0)
|
||||
{
|
||||
attested++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ComplianceSummary(total, unsigned, promoted, deprecated, parityReady, attested);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComplianceSummary(
|
||||
int TotalPacks,
|
||||
int UnsignedPacks,
|
||||
int PromotedPacks,
|
||||
int DeprecatedPacks,
|
||||
int ParityReadyPacks,
|
||||
int AttestedPacks);
|
||||
@@ -0,0 +1,130 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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 IAttestationRepository _attestationRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ExportService(
|
||||
IPackRepository packRepository,
|
||||
IParityRepository parityRepository,
|
||||
ILifecycleRepository lifecycleRepository,
|
||||
IAttestationRepository attestationRepository,
|
||||
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));
|
||||
_attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository));
|
||||
_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 attestations = new List<AttestationRecord>();
|
||||
foreach (var pack in packs)
|
||||
{
|
||||
var records = await _attestationRepository.ListAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
attestations.AddRange(records);
|
||||
}
|
||||
|
||||
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));
|
||||
WriteNdjson(archive, "attestations.ndjson", attestations.OrderBy(a => a.PackId, StringComparer.Ordinal).ThenBy(a => a.Type, 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);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var attestation in attestations.OrderBy(a => a.PackId, StringComparer.Ordinal).ThenBy(a => a.Type, StringComparer.Ordinal))
|
||||
{
|
||||
var content = await _attestationRepository.GetContentAsync(attestation.PackId, attestation.Type, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null || content.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var safeType = attestation.Type.Replace('/', '-').Replace('\\', '-');
|
||||
var entry = archive.CreateEntry($"attestations/{attestation.PackId}.{safeType}.bin", CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(content.AsMemory(0, content.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,150 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# StellaOps.PacksRegistry.Core Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0427-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.PacksRegistry.Core. |
|
||||
| AUDIT-0427-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Core. |
|
||||
| AUDIT-0427-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.PacksRegistry.Infrastructure Agent Charter
|
||||
|
||||
## Mission
|
||||
Provide pack registry persistence, repositories, and infrastructure services.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep behavior deterministic (stable ordering, timestamps, hashes).
|
||||
- Add or update tests for repositories and infrastructure services.
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,72 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,92 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,75 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,135 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,93 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
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,42 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,35 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,36 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,36 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,72 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,36 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
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,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# StellaOps.PacksRegistry.Infrastructure Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0428-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.PacksRegistry.Infrastructure. |
|
||||
| AUDIT-0428-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Infrastructure. |
|
||||
| AUDIT-0428-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
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,47 @@
|
||||
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
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,12 @@
|
||||
# StellaOps.PacksRegistry.Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Provide unit and API tests for PacksRegistry services and repositories.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep tests deterministic (fixed time/IDs, stable ordering).
|
||||
- Extend coverage for packs registry services and persistence edges.
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.IO.Compression;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class ExportServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Offline_seed_includes_metadata_and_content_when_requested()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
var packRepo = new InMemoryPackRepository();
|
||||
var parityRepo = new InMemoryParityRepository();
|
||||
var lifecycleRepo = new InMemoryLifecycleRepository();
|
||||
var attestationRepo = new InMemoryAttestationRepository();
|
||||
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 attestationService = new AttestationService(packRepo, attestationRepo, auditRepo, TimeProvider.System);
|
||||
var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, attestationRepo, 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);
|
||||
await attestationService.UploadAsync(
|
||||
record.PackId,
|
||||
record.TenantId,
|
||||
"dsse",
|
||||
System.Text.Encoding.UTF8.GetBytes("{\"predicate\":\"demo\"}"),
|
||||
"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("attestations.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin"));
|
||||
Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json"));
|
||||
Assert.NotNull(archive.GetEntry($"attestations/{record.PackId}.dsse.bin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class FilePackRepositoryTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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 = CancellationToken.None;
|
||||
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,99 @@
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class PackServiceTests
|
||||
{
|
||||
private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content");
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upload_persists_pack_with_digest()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upload_rejects_when_digest_mismatch()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Rotate_signature_updates_record_and_audits()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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,167 @@
|
||||
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;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
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.RemoveAll<IAttestationRepository>();
|
||||
services.AddSingleton<IPackRepository, InMemoryPackRepository>();
|
||||
services.AddSingleton<IParityRepository, InMemoryParityRepository>();
|
||||
services.AddSingleton<ILifecycleRepository, InMemoryLifecycleRepository>();
|
||||
services.AddSingleton<IAuditRepository, InMemoryAuditRepository>();
|
||||
services.AddSingleton<IAttestationRepository, InMemoryAttestationRepository>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.RemoveAll<PackService>();
|
||||
services.RemoveAll<ParityService>();
|
||||
services.RemoveAll<LifecycleService>();
|
||||
services.RemoveAll<AttestationService>();
|
||||
services.RemoveAll<ComplianceService>();
|
||||
services.RemoveAll<ExportService>();
|
||||
services.AddSingleton<PackService>();
|
||||
services.AddSingleton<ParityService>();
|
||||
services.AddSingleton<LifecycleService>();
|
||||
services.AddSingleton<AttestationService>();
|
||||
services.AddSingleton<ComplianceService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upload_and_download_round_trip()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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 attestationResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/packs/{created.PackId}/attestations",
|
||||
new AttestationUploadRequest
|
||||
{
|
||||
Type = "dsse",
|
||||
Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"predicate\":\"demo\"}")),
|
||||
Notes = "tests"
|
||||
},
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.Created, attestationResponse.StatusCode);
|
||||
|
||||
var compliance = await client.GetFromJsonAsync<ComplianceSummaryResponse>("/api/v1/compliance/summary?tenant=t1", ct);
|
||||
Assert.NotNull(compliance);
|
||||
Assert.Equal(1, compliance!.AttestedPacks);
|
||||
|
||||
var deprecateResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/packs/{created.PackId}/lifecycle",
|
||||
new LifecycleRequest { State = "deprecated", Notes = "tests" },
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.OK, deprecateResponse.StatusCode);
|
||||
|
||||
var activeListResponse = await client.GetFromJsonAsync<PackResponse[]>("/api/v1/packs?tenant=t1", ct);
|
||||
Assert.NotNull(activeListResponse);
|
||||
Assert.DoesNotContain(activeListResponse!, item => string.Equals(item.PackId, created.PackId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var includeDeprecatedList = await client.GetFromJsonAsync<PackResponse[]>("/api/v1/packs?tenant=t1&includeDeprecated=true", ct);
|
||||
Assert.NotNull(includeDeprecatedList);
|
||||
Assert.Contains(includeDeprecatedList!, item => string.Equals(item.PackId, created.PackId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
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"));
|
||||
Assert.NotNull(archive.GetEntry("attestations.ndjson"));
|
||||
Assert.NotNull(archive.GetEntry($"attestations/{created.PackId}.dsse.bin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.PacksRegistry.Tests;
|
||||
|
||||
public sealed class RsaSignatureVerifierTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_succeeds_when_signature_matches_digest()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_fails_on_invalid_signature()
|
||||
{
|
||||
var ct = CancellationToken.None;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<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" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# StellaOps.PacksRegistry.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0432-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.PacksRegistry.Tests. |
|
||||
| AUDIT-0432-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Tests. |
|
||||
| AUDIT-0432-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.PacksRegistry.WebService Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver PacksRegistry HTTP API surface and composition root.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep behavior deterministic (stable ordering, timestamps, hashes).
|
||||
- Add or update WebService endpoint tests and auth/tenant coverage.
|
||||
@@ -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,20 @@
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
namespace StellaOps.PacksRegistry.WebService.Contracts;
|
||||
|
||||
public sealed record ComplianceSummaryResponse(
|
||||
int TotalPacks,
|
||||
int UnsignedPacks,
|
||||
int PromotedPacks,
|
||||
int DeprecatedPacks,
|
||||
int ParityReadyPacks,
|
||||
int AttestedPacks)
|
||||
{
|
||||
public static ComplianceSummaryResponse From(ComplianceSummary summary) => new(
|
||||
summary.TotalPacks,
|
||||
summary.UnsignedPacks,
|
||||
summary.PromotedPacks,
|
||||
summary.DeprecatedPacks,
|
||||
summary.ParityReadyPacks,
|
||||
summary.AttestedPacks);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.PacksRegistry.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
},
|
||||
"applicationUrl": "https://localhost:10340;http://localhost:10341"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.PacksRegistry.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\..\StellaOps.PacksRegistry.__Libraries\StellaOps.PacksRegistry.Persistence\StellaOps.PacksRegistry.Persistence.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj"/>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@StellaOps.PacksRegistry.WebService_HostAddress = http://localhost:5151
|
||||
|
||||
GET {{StellaOps.PacksRegistry.WebService_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.PacksRegistry.WebService Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0433-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.PacksRegistry.WebService. |
|
||||
| AUDIT-0433-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.WebService. |
|
||||
| AUDIT-0433-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-312-003 | DONE | Postgres-first storage driver migration with seed-fs payload contract wired in Program startup (pack/provenance/attestation payload channel). |
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "packsregistry", "version": "1.0" },
|
||||
|
||||
"packsregistry.packs.upload_description": "Uploads a new policy pack as base64-encoded content with optional signature and provenance attachment. Returns 201 Created with the registered pack record and assigned pack ID. Requires the X-StellaOps-Tenant header or a tenantId body field.",
|
||||
"packsregistry.packs.list_description": "Returns the list of policy packs for the specified tenant, optionally excluding deprecated packs. When tenant allowlists are configured, a tenant query parameter or X-StellaOps-Tenant header is required.",
|
||||
"packsregistry.packs.get_description": "Returns the metadata record for the specified pack ID including tenant, digest, provenance URI, and creation timestamp. Returns 403 if the caller's tenant allowlist does not include the pack's tenant. Returns 404 if the pack ID is not found.",
|
||||
"packsregistry.packs.get_content_description": "Downloads the binary content of the specified pack as an octet-stream. The response includes an X-Content-Digest header with the stored digest for integrity verification. Returns 403 if the tenant does not match. Returns 404 if the pack or its content is not found.",
|
||||
"packsregistry.packs.get_provenance_description": "Downloads the provenance document attached to the specified pack as a JSON file. The response includes an X-Provenance-Digest header when a digest is stored. Returns 404 if the pack or its provenance attachment is not found.",
|
||||
"packsregistry.packs.get_manifest_description": "Returns a structured manifest for the specified pack including pack ID, tenant, content digest and size, provenance digest and size, creation timestamp, and attached metadata. Returns 403 if the tenant does not match. Returns 404 if the pack is not found.",
|
||||
"packsregistry.packs.rotate_signature_description": "Replaces the stored signature on a pack with a new signature, optionally using a caller-supplied public key PEM for verification instead of the server default. Returns the updated pack record on success. Returns 400 if the new signature is invalid or rotation fails.",
|
||||
"packsregistry.attestations.upload_description": "Attaches a typed attestation document to a pack as base64-encoded content. The type field identifies the attestation kind (e.g., sbom, scan-result). Returns 201 Created with the stored attestation record. Returns 400 if type or content is missing or the content is not valid base64.",
|
||||
"packsregistry.attestations.list_description": "Returns all attestation records stored for the specified pack. Returns 404 if no attestations exist for the pack. Returns 403 if the X-StellaOps-Tenant header does not match the tenant of the stored attestations.",
|
||||
"packsregistry.attestations.get_content_description": "Downloads the binary content of a specific attestation type for the specified pack. The response includes an X-Attestation-Digest header for integrity verification. Returns 403 if the tenant does not match. Returns 404 if the pack or the named attestation type is not found.",
|
||||
"packsregistry.parity.get_description": "Returns the parity status record for the specified pack, indicating whether the pack content is consistent across mirror sites. Returns 403 if the tenant does not match. Returns 404 if no parity record exists for the pack.",
|
||||
"packsregistry.lifecycle.get_description": "Returns the current lifecycle state record for the specified pack including state name, transition timestamp, and any associated notes. Returns 403 if the tenant does not match. Returns 404 if no lifecycle record exists for the pack.",
|
||||
"packsregistry.lifecycle.set_description": "Transitions the specified pack to a new lifecycle state (e.g., active, deprecated, archived) with optional notes. Returns the updated lifecycle record. Returns 400 if the state value is missing or the transition is invalid.",
|
||||
"packsregistry.parity.set_description": "Records the parity check result for the specified pack, marking it as verified, mismatch, or unknown with optional notes. Returns the updated parity record. Returns 400 if the status value is missing or the parity update fails.",
|
||||
"packsregistry.export.offline_seed_description": "Generates a ZIP archive containing all packs for the specified tenant, optionally including binary content and provenance documents, suitable for seeding an air-gapped PacksRegistry instance. When tenant allowlists are configured, a tenant ID is required.",
|
||||
"packsregistry.mirrors.upsert_description": "Creates or updates a mirror registration for the specified tenant, associating a mirror ID with an upstream URL and enabled state. Returns 201 Created with the stored mirror record. Returns 400 if required fields are missing.",
|
||||
"packsregistry.mirrors.list_description": "Returns all mirror registrations for the specified tenant, or all mirrors if no tenant filter is applied. Returns 403 if the caller's tenant allowlist excludes the requested tenant.",
|
||||
"packsregistry.mirrors.mark_sync_description": "Records the outcome of a mirror synchronization attempt for the specified mirror ID, updating its sync status and optional notes. Returns the updated mirror record. Returns 404 if the mirror ID is not found.",
|
||||
"packsregistry.compliance.summary_description": "Returns a compliance summary for the specified tenant's pack collection including signed pack count, unsigned count, packs with attestations, deprecated packs, and mirror sync status breakdown. Returns 403 if the tenant is not allowed.",
|
||||
|
||||
"packsregistry.error.tenant_missing_header_or_body": "X-StellaOps-Tenant header or tenantId is required.",
|
||||
"packsregistry.error.content_missing": "Content (base64) is required.",
|
||||
"packsregistry.error.content_base64_invalid": "Content must be valid base64.",
|
||||
"packsregistry.error.tenant_missing_query_or_header": "tenant query parameter or X-StellaOps-Tenant header is required when tenant allowlists are configured.",
|
||||
"packsregistry.error.tenant_missing_header": "X-StellaOps-Tenant header is required.",
|
||||
"packsregistry.error.tenant_missing_body_or_header": "tenantId or X-StellaOps-Tenant header is required when tenant allowlists are configured.",
|
||||
"packsregistry.error.signature_missing": "signature is required.",
|
||||
"packsregistry.error.attestation_missing": "type and content are required.",
|
||||
"packsregistry.error.state_missing": "state is required.",
|
||||
"packsregistry.error.status_missing": "status is required.",
|
||||
"packsregistry.error.mirror_missing": "id and upstream are required."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","type":"dsse","digest":"sha256:934cd2191ed43ee19cd0a30506fa813f58ac41ff78df5e3154eda5c507b570f1","createdAtUtc":"2026-02-11T07:57:29.9780688+00:00","notes":"qa attestation"}
|
||||
@@ -0,0 +1 @@
|
||||
{"predicate":"demo"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"pack.uploaded","occurredAtUtc":"2026-02-11T07:57:29.8146776+00:00","actor":null,"notes":"https://example/prov.json"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"mirror.upserted","occurredAtUtc":"2026-02-11T07:57:29.9030336+00:00","actor":null,"notes":"https://mirror.example.local/repo"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"mirror.sync","occurredAtUtc":"2026-02-11T07:57:29.9430852+00:00","actor":null,"notes":"synced"}
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"attestation.uploaded","occurredAtUtc":"2026-02-11T07:57:29.9780688+00:00","actor":null,"notes":"qa attestation"}
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"lifecycle.updated","occurredAtUtc":"2026-02-11T07:57:30.0554898+00:00","actor":null,"notes":"qa deprecate"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"offline.seed.exported","occurredAtUtc":"2026-02-11T07:57:30.1450257+00:00","actor":null,"notes":"with-content"}
|
||||
@@ -0,0 +1 @@
|
||||
hello-pack-content
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","name":"demo-pack","version":"1.0.0","tenantId":"tenant-a","digest":"sha256:521aae173000ef3fdf35987542609b641ccae1882bfe06e616569b8e2e877e3e","signature":null,"provenanceUri":"https://example/prov.json","provenanceDigest":"sha256:60191507d86c569c572ba250b28ce4b97eb70ab7c50225ffbf0ab82aea66c85a","createdAtUtc":"2026-02-11T07:57:29.8146776+00:00","metadata":null}
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","state":"deprecated","notes":"qa deprecate","updatedAtUtc":"2026-02-11T07:57:30.0554898+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"id":"mirror-a","tenantId":"tenant-a","upstreamUri":"https://mirror.example.local/repo","enabled":true,"status":"enabled","updatedAtUtc":"2026-02-11T07:57:29.9030336+00:00","notes":"initial mirror","lastSuccessfulSyncUtc":null}
|
||||
{"id":"mirror-a","tenantId":"tenant-a","upstreamUri":"https://mirror.example.local/repo","enabled":true,"status":"synced","updatedAtUtc":"2026-02-11T07:57:29.9430852+00:00","notes":"sync ok","lastSuccessfulSyncUtc":"2026-02-11T07:57:29.9431112+00:00"}
|
||||
@@ -0,0 +1 @@
|
||||
{"provenance":true}
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","type":"dsse","digest":"sha256:934cd2191ed43ee19cd0a30506fa813f58ac41ff78df5e3154eda5c507b570f1","createdAtUtc":"2026-02-11T08:03:51.8803767+00:00","notes":"qa attestation"}
|
||||
@@ -0,0 +1 @@
|
||||
{"predicate":"demo"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"pack.uploaded","occurredAtUtc":"2026-02-11T08:03:51.6949543+00:00","actor":null,"notes":"https://example/prov.json"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"mirror.upserted","occurredAtUtc":"2026-02-11T08:03:51.7993693+00:00","actor":null,"notes":"https://mirror.example.local/repo"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"mirror.sync","occurredAtUtc":"2026-02-11T08:03:51.8461881+00:00","actor":null,"notes":"synced"}
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"attestation.uploaded","occurredAtUtc":"2026-02-11T08:03:51.8803767+00:00","actor":null,"notes":"qa attestation"}
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"lifecycle.updated","occurredAtUtc":"2026-02-11T08:03:51.968221+00:00","actor":null,"notes":"qa deprecate"}
|
||||
{"packId":null,"tenantId":"tenant-a","event":"offline.seed.exported","occurredAtUtc":"2026-02-11T08:03:52.0633922+00:00","actor":null,"notes":"with-content"}
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","event":"parity.updated","occurredAtUtc":"2026-02-11T08:05:50.9258486+00:00","actor":null,"notes":"primary updated"}
|
||||
@@ -0,0 +1 @@
|
||||
hello-pack-content
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","name":"demo-pack","version":"1.0.0","tenantId":"tenant-a","digest":"sha256:521aae173000ef3fdf35987542609b641ccae1882bfe06e616569b8e2e877e3e","signature":null,"provenanceUri":"https://example/prov.json","provenanceDigest":"sha256:60191507d86c569c572ba250b28ce4b97eb70ab7c50225ffbf0ab82aea66c85a","createdAtUtc":"2026-02-11T08:03:51.6949543+00:00","metadata":null}
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","state":"deprecated","notes":"qa deprecate","updatedAtUtc":"2026-02-11T08:03:51.968221+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"id":"mirror-a","tenantId":"tenant-a","upstreamUri":"https://mirror.example.local/repo","enabled":true,"status":"enabled","updatedAtUtc":"2026-02-11T08:03:51.7993693+00:00","notes":"initial mirror","lastSuccessfulSyncUtc":null}
|
||||
{"id":"mirror-a","tenantId":"tenant-a","upstreamUri":"https://mirror.example.local/repo","enabled":true,"status":"synced","updatedAtUtc":"2026-02-11T08:03:51.8461881+00:00","notes":"sync ok","lastSuccessfulSyncUtc":"2026-02-11T08:03:51.8462195+00:00"}
|
||||
@@ -0,0 +1 @@
|
||||
{"packId":"demo-pack@1.0.0","tenantId":"tenant-a","status":"out_of_sync","notes":"primary updated","updatedAtUtc":"2026-02-11T08:05:50.9258486+00:00"}
|
||||
@@ -0,0 +1 @@
|
||||
{"provenance":true}
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.PacksRegistry.Worker Agent Charter
|
||||
|
||||
## Mission
|
||||
Run background processing for pack registry tasks.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep behavior deterministic (stable ordering, timestamps, hashes).
|
||||
- Add or update worker loop tests and deterministic timing controls.
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.PacksRegistry.Worker;
|
||||
using StellaOps.Worker.Health;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapWorkerHealthEndpoints();
|
||||
app.Run();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"StellaOps.PacksRegistry.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<UserSecretsId>dotnet-StellaOps.PacksRegistry.Worker-a5c025f8-62a4-498b-928b-5ed8f27c53de</UserSecretsId>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# StellaOps.PacksRegistry.Worker Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0434-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.PacksRegistry.Worker. |
|
||||
| AUDIT-0434-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Worker. |
|
||||
| AUDIT-0434-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user