save dev progress

This commit is contained in:
StellaOps Bot
2025-12-26 00:32:35 +02:00
parent aa70af062e
commit ed3079543c
142 changed files with 23771 additions and 232 deletions

View File

@@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Merge.Backport;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
@@ -262,8 +263,61 @@ internal static class CanonicalAdvisoryEndpointExtensions
.WithSummary("Update canonical advisory status")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// GET /api/v1/canonical/{id}/provenance - Get provenance scopes for canonical
group.MapGet("/{id:guid}/provenance", async (
Guid id,
IProvenanceScopeService? provenanceService,
ICanonicalAdvisoryService canonicalService,
HttpContext context,
CancellationToken ct) =>
{
// Verify canonical exists
var canonical = await canonicalService.GetByIdAsync(id, ct).ConfigureAwait(false);
if (canonical is null)
{
return HttpResults.NotFound(new { error = "Canonical advisory not found", id });
}
if (provenanceService is null)
{
return HttpResults.Ok(new ProvenanceScopeListResponse
{
CanonicalId = id,
Scopes = [],
TotalCount = 0
});
}
var scopes = await provenanceService.GetByCanonicalIdAsync(id, ct).ConfigureAwait(false);
return HttpResults.Ok(new ProvenanceScopeListResponse
{
CanonicalId = id,
Scopes = scopes.Select(MapToProvenanceResponse).ToList(),
TotalCount = scopes.Count
});
})
.WithName("GetCanonicalProvenance")
.WithSummary("Get provenance scopes for canonical advisory")
.WithDescription("Returns distro-specific backport and patch provenance information for a canonical advisory")
.Produces<ProvenanceScopeListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
}
private static ProvenanceScopeResponse MapToProvenanceResponse(ProvenanceScope scope) => new()
{
Id = scope.Id,
DistroRelease = scope.DistroRelease,
BackportSemver = scope.BackportSemver,
PatchId = scope.PatchId,
PatchOrigin = scope.PatchOrigin?.ToString(),
EvidenceRef = scope.EvidenceRef,
Confidence = scope.Confidence,
CreatedAt = scope.CreatedAt,
UpdatedAt = scope.UpdatedAt
};
private static CanonicalAdvisoryResponse MapToResponse(
CanonicalAdvisory canonical,
Interest.Models.InterestScore? score = null) => new()
@@ -399,6 +453,32 @@ public sealed record BatchIngestSummary
public int Conflicts { get; init; }
}
/// <summary>
/// Response for a provenance scope.
/// </summary>
public sealed record ProvenanceScopeResponse
{
public Guid Id { get; init; }
public required string DistroRelease { get; init; }
public string? BackportSemver { get; init; }
public string? PatchId { get; init; }
public string? PatchOrigin { get; init; }
public Guid? EvidenceRef { get; init; }
public double Confidence { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Response for a list of provenance scopes.
/// </summary>
public sealed record ProvenanceScopeListResponse
{
public Guid CanonicalId { get; init; }
public IReadOnlyList<ProvenanceScopeResponse> Scopes { get; init; } = [];
public int TotalCount { get; init; }
}
#endregion
#region Request DTOs

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Federation.Export;
using StellaOps.Concelier.Federation.Import;
using StellaOps.Concelier.Federation.Models;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Results;
@@ -128,5 +129,332 @@ internal static class FederationEndpointExtensions
.WithName("GetFederationStatus")
.WithSummary("Get federation configuration status")
.Produces<object>(200);
// POST /api/v1/federation/import - Import a bundle
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 25-26.
group.MapPost("/import", async (
HttpContext context,
IBundleImportService importService,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken,
[FromQuery(Name = "dry_run")] bool dryRun = false,
[FromQuery(Name = "skip_signature")] bool skipSignature = false,
[FromQuery(Name = "on_conflict")] string? onConflict = null,
[FromQuery] bool force = false) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
// Validate content type
var contentType = context.Request.ContentType;
if (string.IsNullOrEmpty(contentType) ||
(!contentType.Contains("application/zstd") &&
!contentType.Contains("application/octet-stream")))
{
return HttpResults.BadRequest(new { error = "Content-Type must be application/zstd or application/octet-stream" });
}
// Parse conflict resolution
var conflictResolution = ConflictResolution.PreferRemote;
if (!string.IsNullOrEmpty(onConflict))
{
if (!Enum.TryParse<ConflictResolution>(onConflict, ignoreCase: true, out conflictResolution))
{
return HttpResults.BadRequest(new { error = "on_conflict must be one of: PreferRemote, PreferLocal, Fail" });
}
}
var importOptions = new BundleImportOptions
{
DryRun = dryRun,
SkipSignatureVerification = skipSignature,
OnConflict = conflictResolution,
Force = force
};
// Stream request body directly to import service
var result = await importService.ImportAsync(
context.Request.Body,
importOptions,
cancellationToken);
if (!result.Success)
{
return HttpResults.UnprocessableEntity(new
{
success = false,
bundle_hash = result.BundleHash,
failure_reason = result.FailureReason,
duration_ms = result.Duration.TotalMilliseconds
});
}
return HttpResults.Ok(new
{
success = true,
bundle_hash = result.BundleHash,
imported_cursor = result.ImportedCursor,
counts = new
{
canonical_created = result.Counts.CanonicalCreated,
canonical_updated = result.Counts.CanonicalUpdated,
canonical_skipped = result.Counts.CanonicalSkipped,
edges_added = result.Counts.EdgesAdded,
deletions_processed = result.Counts.DeletionsProcessed,
total = result.Counts.Total
},
conflicts = result.Conflicts.Select(c => new
{
merge_hash = c.MergeHash,
field = c.Field,
local_value = c.LocalValue,
remote_value = c.RemoteValue,
resolution = c.Resolution.ToString().ToLowerInvariant()
}),
duration_ms = result.Duration.TotalMilliseconds,
dry_run = dryRun
});
})
.WithName("ImportFederationBundle")
.WithSummary("Import a federation bundle")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(400)
.ProducesProblem(422)
.ProducesProblem(503)
.DisableAntiforgery();
// POST /api/v1/federation/import/validate - Validate bundle without importing
group.MapPost("/import/validate", async (
HttpContext context,
IBundleImportService importService,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var result = await importService.ValidateAsync(
context.Request.Body,
cancellationToken);
return HttpResults.Ok(new
{
is_valid = result.IsValid,
errors = result.Errors,
warnings = result.Warnings,
hash_valid = result.HashValid,
signature_valid = result.SignatureValid,
cursor_valid = result.CursorValid
});
})
.WithName("ValidateFederationBundle")
.WithSummary("Validate a bundle without importing")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(503)
.DisableAntiforgery();
// POST /api/v1/federation/import/preview - Preview import
group.MapPost("/import/preview", async (
HttpContext context,
IBundleImportService importService,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var preview = await importService.PreviewAsync(
context.Request.Body,
cancellationToken);
return HttpResults.Ok(new
{
is_valid = preview.IsValid,
is_duplicate = preview.IsDuplicate,
current_cursor = preview.CurrentCursor,
manifest = new
{
version = preview.Manifest.Version,
site_id = preview.Manifest.SiteId,
export_cursor = preview.Manifest.ExportCursor,
bundle_hash = preview.Manifest.BundleHash,
exported_at = preview.Manifest.ExportedAt,
counts = new
{
canonicals = preview.Manifest.Counts?.Canonicals ?? 0,
edges = preview.Manifest.Counts?.Edges ?? 0,
deletions = preview.Manifest.Counts?.Deletions ?? 0,
total = preview.Manifest.Counts?.Total ?? 0
}
},
errors = preview.Errors,
warnings = preview.Warnings
});
})
.WithName("PreviewFederationImport")
.WithSummary("Preview what import would do")
.Accepts<Stream>("application/zstd")
.Produces<object>(200)
.ProducesProblem(503)
.DisableAntiforgery();
// GET /api/v1/federation/sites - List all federation sites
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 30.
group.MapGet("/sites", async (
HttpContext context,
ISyncLedgerRepository ledgerRepository,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken,
[FromQuery(Name = "enabled_only")] bool enabledOnly = false) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var sites = await ledgerRepository.GetAllPoliciesAsync(enabledOnly, cancellationToken);
return HttpResults.Ok(new
{
sites = sites.Select(s => new
{
site_id = s.SiteId,
display_name = s.DisplayName,
enabled = s.Enabled,
last_sync_at = s.LastSyncAt,
last_cursor = s.LastCursor,
total_imports = s.TotalImports,
allowed_sources = s.AllowedSources,
max_bundle_size_bytes = s.MaxBundleSizeBytes
}),
count = sites.Count
});
})
.WithName("ListFederationSites")
.WithSummary("List all federation sites")
.Produces<object>(200)
.ProducesProblem(503);
// GET /api/v1/federation/sites/{siteId} - Get site details
group.MapGet("/sites/{siteId}", async (
HttpContext context,
ISyncLedgerRepository ledgerRepository,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string siteId,
CancellationToken cancellationToken) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var site = await ledgerRepository.GetPolicyAsync(siteId, cancellationToken);
if (site == null)
{
return HttpResults.NotFound(new { error = $"Site '{siteId}' not found" });
}
// Get recent sync history
var history = new List<object>();
await foreach (var entry in ledgerRepository.GetHistoryAsync(siteId, 10, cancellationToken))
{
history.Add(new
{
cursor = entry.Cursor,
bundle_hash = entry.BundleHash,
item_count = entry.ItemCount,
exported_at = entry.ExportedAt,
imported_at = entry.ImportedAt
});
}
return HttpResults.Ok(new
{
site_id = site.SiteId,
display_name = site.DisplayName,
enabled = site.Enabled,
last_sync_at = site.LastSyncAt,
last_cursor = site.LastCursor,
total_imports = site.TotalImports,
allowed_sources = site.AllowedSources,
max_bundle_size_bytes = site.MaxBundleSizeBytes,
recent_history = history
});
})
.WithName("GetFederationSite")
.WithSummary("Get federation site details")
.Produces<object>(200)
.ProducesProblem(404)
.ProducesProblem(503);
// PUT /api/v1/federation/sites/{siteId}/policy - Update site policy
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 31.
group.MapPut("/sites/{siteId}/policy", async (
HttpContext context,
ISyncLedgerRepository ledgerRepository,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string siteId,
[FromBody] SitePolicyUpdateRequest request,
CancellationToken cancellationToken) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var existing = await ledgerRepository.GetPolicyAsync(siteId, cancellationToken);
var policy = new SitePolicy
{
SiteId = siteId,
DisplayName = request.DisplayName ?? existing?.DisplayName,
Enabled = request.Enabled ?? existing?.Enabled ?? true,
AllowedSources = request.AllowedSources ?? existing?.AllowedSources,
MaxBundleSizeBytes = request.MaxBundleSizeBytes ?? existing?.MaxBundleSizeBytes,
LastSyncAt = existing?.LastSyncAt,
LastCursor = existing?.LastCursor,
TotalImports = existing?.TotalImports ?? 0
};
await ledgerRepository.UpsertPolicyAsync(policy, cancellationToken);
return HttpResults.Ok(new
{
site_id = policy.SiteId,
display_name = policy.DisplayName,
enabled = policy.Enabled,
allowed_sources = policy.AllowedSources,
max_bundle_size_bytes = policy.MaxBundleSizeBytes
});
})
.WithName("UpdateFederationSitePolicy")
.WithSummary("Update federation site policy")
.Produces<object>(200)
.ProducesProblem(400)
.ProducesProblem(503);
}
}
/// <summary>
/// Request body for updating site policy.
/// </summary>
public sealed record SitePolicyUpdateRequest
{
public string? DisplayName { get; init; }
public bool? Enabled { get; init; }
public List<string>? AllowedSources { get; init; }
public long? MaxBundleSizeBytes { get; init; }
}

View File

@@ -212,6 +212,49 @@ internal static class SbomEndpointExtensions
.Produces<SbomRematchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// PATCH /api/v1/sboms/{digest} - Incrementally update SBOM (add/remove components)
group.MapPatch("/sboms/{digest}", async (
string digest,
[FromBody] SbomDeltaRequest request,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
try
{
var delta = new SbomDeltaInput
{
AddedPurls = request.AddedPurls ?? [],
RemovedPurls = request.RemovedPurls ?? [],
ReachabilityMap = request.ReachabilityMap,
DeploymentMap = request.DeploymentMap,
IsFullReplacement = request.IsFullReplacement
};
var result = await registryService.UpdateSbomDeltaAsync(digest, delta, ct)
.ConfigureAwait(false);
return HttpResults.Ok(new SbomDeltaResponse
{
SbomDigest = digest,
SbomId = result.Registration.Id,
AddedPurls = request.AddedPurls?.Count ?? 0,
RemovedPurls = request.RemovedPurls?.Count ?? 0,
TotalComponents = result.Registration.ComponentCount,
AdvisoriesMatched = result.Matches.Count,
ScoresUpdated = result.ScoresUpdated,
ProcessingTimeMs = result.ProcessingTimeMs
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return HttpResults.NotFound(new { error = ex.Message });
}
})
.WithName("UpdateSbomDelta")
.WithSummary("Incrementally update SBOM components (add/remove)")
.Produces<SbomDeltaResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/sboms/stats - Get SBOM registry statistics
group.MapGet("/sboms/stats", async (
[FromQuery] string? tenantId,
@@ -347,4 +390,25 @@ public sealed record SbomStatsResponse
public double AverageMatchesPerSbom { get; init; }
}
public sealed record SbomDeltaRequest
{
public IReadOnlyList<string>? AddedPurls { get; init; }
public IReadOnlyList<string>? RemovedPurls { get; init; }
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
public IReadOnlyDictionary<string, bool>? DeploymentMap { get; init; }
public bool IsFullReplacement { get; init; }
}
public sealed record SbomDeltaResponse
{
public required string SbomDigest { get; init; }
public Guid SbomId { get; init; }
public int AddedPurls { get; init; }
public int RemovedPurls { get; init; }
public int TotalComponents { get; init; }
public int AdvisoriesMatched { get; init; }
public int ScoresUpdated { get; init; }
public double ProcessingTimeMs { get; init; }
}
#endregion