save dev progress
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user