up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-10-19 10:38:55 +03:00
parent c4980d9625
commit daa6a4ae8c
250 changed files with 17967 additions and 66 deletions

View File

@@ -0,0 +1,173 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Core.Contracts;
[JsonConverter(typeof(ScanJobIdJsonConverter))]
public readonly record struct ScanJobId(Guid Value)
{
public static readonly ScanJobId Empty = new(Guid.Empty);
public override string ToString()
=> Value.ToString("n", CultureInfo.InvariantCulture);
public static ScanJobId From(Guid value)
=> new(value);
public static bool TryParse(string? text, out ScanJobId id)
{
if (Guid.TryParse(text, out var guid))
{
id = new ScanJobId(guid);
return true;
}
id = Empty;
return false;
}
}
[JsonConverter(typeof(JsonStringEnumConverter<ScanJobStatus>))]
public enum ScanJobStatus
{
Unknown = 0,
Pending,
Queued,
Running,
Succeeded,
Failed,
Cancelled
}
public sealed class ScanJob
{
private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal));
[JsonConstructor]
public ScanJob(
ScanJobId id,
ScanJobStatus status,
string imageReference,
string? imageDigest,
DateTimeOffset createdAt,
DateTimeOffset? updatedAt,
string correlationId,
string? tenantId,
IReadOnlyDictionary<string, string>? metadata = null,
ScannerError? failure = null)
{
if (string.IsNullOrWhiteSpace(imageReference))
{
throw new ArgumentException("Image reference cannot be null or whitespace.", nameof(imageReference));
}
if (string.IsNullOrWhiteSpace(correlationId))
{
throw new ArgumentException("Correlation identifier cannot be null or whitespace.", nameof(correlationId));
}
Id = id;
Status = status;
ImageReference = imageReference.Trim();
ImageDigest = NormalizeDigest(imageDigest);
CreatedAt = ScannerTimestamps.Normalize(createdAt);
UpdatedAt = updatedAt is null ? null : ScannerTimestamps.Normalize(updatedAt.Value);
CorrelationId = correlationId;
TenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim();
Metadata = metadata is null or { Count: 0 }
? EmptyMetadata
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
Failure = failure;
}
[JsonPropertyName("id")]
[JsonPropertyOrder(0)]
public ScanJobId Id { get; }
[JsonPropertyName("status")]
[JsonPropertyOrder(1)]
public ScanJobStatus Status { get; init; }
[JsonPropertyName("imageReference")]
[JsonPropertyOrder(2)]
public string ImageReference { get; }
[JsonPropertyName("imageDigest")]
[JsonPropertyOrder(3)]
public string? ImageDigest { get; }
[JsonPropertyName("createdAt")]
[JsonPropertyOrder(4)]
public DateTimeOffset CreatedAt { get; }
[JsonPropertyName("updatedAt")]
[JsonPropertyOrder(5)]
public DateTimeOffset? UpdatedAt { get; init; }
[JsonPropertyName("correlationId")]
[JsonPropertyOrder(6)]
public string CorrelationId { get; }
[JsonPropertyName("tenantId")]
[JsonPropertyOrder(7)]
public string? TenantId { get; }
[JsonPropertyName("metadata")]
[JsonPropertyOrder(8)]
public IReadOnlyDictionary<string, string> Metadata { get; }
[JsonPropertyName("failure")]
[JsonPropertyOrder(9)]
public ScannerError? Failure { get; init; }
public ScanJob WithStatus(ScanJobStatus status, DateTimeOffset? updatedAt = null)
=> new(
Id,
status,
ImageReference,
ImageDigest,
CreatedAt,
updatedAt ?? UpdatedAt ?? CreatedAt,
CorrelationId,
TenantId,
Metadata,
Failure);
public ScanJob WithFailure(ScannerError failure, DateTimeOffset? updatedAt = null, TimeProvider? timeProvider = null)
=> new(
Id,
ScanJobStatus.Failed,
ImageReference,
ImageDigest,
CreatedAt,
updatedAt ?? ScannerTimestamps.UtcNow(timeProvider),
CorrelationId,
TenantId,
Metadata,
failure);
private static string? NormalizeDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var trimmed = digest.Trim();
if (!trimmed.StartsWith("sha", StringComparison.OrdinalIgnoreCase))
{
return trimmed;
}
var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return trimmed.ToLowerInvariant();
}
return $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Core.Contracts;
internal sealed class ScanJobIdJsonConverter : JsonConverter<ScanJobId>
{
public override ScanJobId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException("Expected scan job identifier to be a string.");
}
var value = reader.GetString();
if (!ScanJobId.TryParse(value, out var id))
{
throw new JsonException("Invalid scan job identifier.");
}
return id;
}
public override void Write(Utf8JsonWriter writer, ScanJobId value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}

View File

@@ -0,0 +1,121 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Core.Contracts;
[JsonConverter(typeof(JsonStringEnumConverter<ScanStage>))]
public enum ScanStage
{
Unknown = 0,
ResolveImage,
FetchLayers,
MountLayers,
AnalyzeOperatingSystem,
AnalyzeLanguageEcosystems,
AnalyzeNativeArtifacts,
ComposeSbom,
BuildDiffs,
EmitArtifacts,
SignArtifacts,
Complete
}
[JsonConverter(typeof(JsonStringEnumConverter<ScanProgressEventKind>))]
public enum ScanProgressEventKind
{
Progress = 0,
StageStarted,
StageCompleted,
Warning,
Error
}
public sealed class ScanProgressEvent
{
private static readonly IReadOnlyDictionary<string, string> EmptyAttributes =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal));
[JsonConstructor]
public ScanProgressEvent(
ScanJobId jobId,
ScanStage stage,
ScanProgressEventKind kind,
int sequence,
DateTimeOffset timestamp,
double? percentComplete = null,
string? message = null,
IReadOnlyDictionary<string, string>? attributes = null,
ScannerError? error = null)
{
if (sequence < 0)
{
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence cannot be negative.");
}
JobId = jobId;
Stage = stage;
Kind = kind;
Sequence = sequence;
Timestamp = ScannerTimestamps.Normalize(timestamp);
PercentComplete = percentComplete is < 0 or > 100 ? null : percentComplete;
Message = message is { Length: > 0 } ? message.Trim() : null;
Attributes = attributes is null or { Count: 0 }
? EmptyAttributes
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
Error = error;
}
[JsonPropertyName("jobId")]
[JsonPropertyOrder(0)]
public ScanJobId JobId { get; }
[JsonPropertyName("stage")]
[JsonPropertyOrder(1)]
public ScanStage Stage { get; }
[JsonPropertyName("kind")]
[JsonPropertyOrder(2)]
public ScanProgressEventKind Kind { get; }
[JsonPropertyName("sequence")]
[JsonPropertyOrder(3)]
public int Sequence { get; }
[JsonPropertyName("timestamp")]
[JsonPropertyOrder(4)]
public DateTimeOffset Timestamp { get; }
[JsonPropertyName("percentComplete")]
[JsonPropertyOrder(5)]
public double? PercentComplete { get; }
[JsonPropertyName("message")]
[JsonPropertyOrder(6)]
public string? Message { get; }
[JsonPropertyName("attributes")]
[JsonPropertyOrder(7)]
public IReadOnlyDictionary<string, string> Attributes { get; }
[JsonPropertyName("error")]
[JsonPropertyOrder(8)]
public ScannerError? Error { get; }
public ScanProgressEvent With(
ScanProgressEventKind? kind = null,
double? percentComplete = null,
string? message = null,
IReadOnlyDictionary<string, string>? attributes = null,
ScannerError? error = null)
=> new(
JobId,
Stage,
kind ?? Kind,
Sequence,
Timestamp,
percentComplete ?? PercentComplete,
message ?? Message,
attributes ?? Attributes,
error ?? Error);
}

View File

@@ -0,0 +1,110 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Core.Contracts;
[JsonConverter(typeof(JsonStringEnumConverter<ScannerErrorCode>))]
public enum ScannerErrorCode
{
Unknown = 0,
InvalidImageReference,
ImageNotFound,
AuthorizationFailed,
QueueUnavailable,
StorageUnavailable,
AnalyzerFailure,
ExportFailure,
SigningFailure,
RuntimeFailure,
Timeout,
Cancelled,
PluginViolation
}
[JsonConverter(typeof(JsonStringEnumConverter<ScannerErrorSeverity>))]
public enum ScannerErrorSeverity
{
Warning = 0,
Error,
Fatal
}
public sealed class ScannerError
{
private static readonly IReadOnlyDictionary<string, string> EmptyDetails =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal));
[JsonConstructor]
public ScannerError(
ScannerErrorCode code,
ScannerErrorSeverity severity,
string message,
DateTimeOffset timestamp,
bool retryable,
IReadOnlyDictionary<string, string>? details = null,
string? stage = null,
string? component = null)
{
if (string.IsNullOrWhiteSpace(message))
{
throw new ArgumentException("Error message cannot be null or whitespace.", nameof(message));
}
Code = code;
Severity = severity;
Message = message.Trim();
Timestamp = ScannerTimestamps.Normalize(timestamp);
Retryable = retryable;
Stage = stage;
Component = component;
Details = details is null or { Count: 0 }
? EmptyDetails
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(details, StringComparer.Ordinal));
}
[JsonPropertyName("code")]
[JsonPropertyOrder(0)]
public ScannerErrorCode Code { get; }
[JsonPropertyName("severity")]
[JsonPropertyOrder(1)]
public ScannerErrorSeverity Severity { get; }
[JsonPropertyName("message")]
[JsonPropertyOrder(2)]
public string Message { get; }
[JsonPropertyName("timestamp")]
[JsonPropertyOrder(3)]
public DateTimeOffset Timestamp { get; }
[JsonPropertyName("retryable")]
[JsonPropertyOrder(4)]
public bool Retryable { get; }
[JsonPropertyName("stage")]
[JsonPropertyOrder(5)]
public string? Stage { get; }
[JsonPropertyName("component")]
[JsonPropertyOrder(6)]
public string? Component { get; }
[JsonPropertyName("details")]
[JsonPropertyOrder(7)]
public IReadOnlyDictionary<string, string> Details { get; }
public ScannerError WithDetail(string key, string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var mutable = new Dictionary<string, string>(Details, StringComparer.Ordinal)
{
[key] = value
};
return new ScannerError(Code, Severity, Message, Timestamp, Retryable, mutable, Stage, Component);
}
}