up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,44 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read access to the scheduler impact index.
|
||||
/// </summary>
|
||||
public interface IImpactIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the impacted image set for the provided package URLs.
|
||||
/// </summary>
|
||||
/// <param name="purls">Package URLs to look up.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask<ImpactSet> ResolveByPurlsAsync(
|
||||
IEnumerable<string> purls,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves impacted images by vulnerability identifiers if the index has the mapping available.
|
||||
/// </summary>
|
||||
/// <param name="vulnerabilityIds">Vulnerability identifiers to look up.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
|
||||
IEnumerable<string> vulnerabilityIds,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all tracked images for the provided selector.
|
||||
/// </summary>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to images with entrypoint usage.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read access to the scheduler impact index.
|
||||
/// </summary>
|
||||
public interface IImpactIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the impacted image set for the provided package URLs.
|
||||
/// </summary>
|
||||
/// <param name="purls">Package URLs to look up.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask<ImpactSet> ResolveByPurlsAsync(
|
||||
IEnumerable<string> purls,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves impacted images by vulnerability identifiers if the index has the mapping available.
|
||||
/// </summary>
|
||||
/// <param name="vulnerabilityIds">Vulnerability identifiers to look up.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
|
||||
IEnumerable<string> vulnerabilityIds,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all tracked images for the provided selector.
|
||||
/// </summary>
|
||||
/// <param name="selector">Selector scoping the query.</param>
|
||||
/// <param name="usageOnly">When true, restricts results to images with entrypoint usage.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask<ImpactSet> ResolveAllAsync(
|
||||
Selector selector,
|
||||
bool usageOnly,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
public sealed record ImpactImageRecord(
|
||||
int ImageId,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
string Registry,
|
||||
string Repository,
|
||||
ImmutableArray<string> Namespaces,
|
||||
ImmutableArray<string> Tags,
|
||||
ImmutableSortedDictionary<string, string> Labels,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<string> Components,
|
||||
ImmutableArray<string> EntrypointComponents);
|
||||
int ImageId,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
string Registry,
|
||||
string Repository,
|
||||
ImmutableArray<string> Namespaces,
|
||||
ImmutableArray<string> Tags,
|
||||
ImmutableSortedDictionary<string, string> Labels,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<string> Components,
|
||||
ImmutableArray<string> EntrypointComponents);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// ServiceCollection helpers for wiring the fixture-backed impact index.
|
||||
/// </summary>
|
||||
public static class ImpactIndexServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddImpactIndexStub(
|
||||
this IServiceCollection services,
|
||||
Action<ImpactIndexStubOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var options = new ImpactIndexStubOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(options);
|
||||
services.TryAddSingleton<IImpactIndex, FixtureImpactIndex>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// ServiceCollection helpers for wiring the fixture-backed impact index.
|
||||
/// </summary>
|
||||
public static class ImpactIndexServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddImpactIndexStub(
|
||||
this IServiceCollection services,
|
||||
Action<ImpactIndexStubOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var options = new ImpactIndexStubOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(options);
|
||||
services.TryAddSingleton<IImpactIndex, FixtureImpactIndex>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling the fixture-backed impact index stub.
|
||||
/// </summary>
|
||||
public sealed class ImpactIndexStubOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional absolute or relative directory containing BOM-Index JSON fixtures.
|
||||
/// When not supplied or not found, embedded fixtures ship with the assembly are used instead.
|
||||
/// </summary>
|
||||
public string? FixtureDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot identifier reported in the generated <see cref="StellaOps.Scheduler.Models.ImpactSet"/>.
|
||||
/// Defaults to <c>samples/impact-index-stub</c>.
|
||||
/// </summary>
|
||||
public string SnapshotId { get; set; } = "samples/impact-index-stub";
|
||||
}
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling the fixture-backed impact index stub.
|
||||
/// </summary>
|
||||
public sealed class ImpactIndexStubOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional absolute or relative directory containing BOM-Index JSON fixtures.
|
||||
/// When not supplied or not found, embedded fixtures ship with the assembly are used instead.
|
||||
/// </summary>
|
||||
public string? FixtureDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot identifier reported in the generated <see cref="StellaOps.Scheduler.Models.ImpactSet"/>.
|
||||
/// Defaults to <c>samples/impact-index-stub</c>.
|
||||
/// </summary>
|
||||
public string SnapshotId { get; set; } = "samples/impact-index-stub";
|
||||
}
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Collections.Special;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
|
||||
internal sealed record BomIndexComponent(string Key, bool UsedByEntrypoint);
|
||||
|
||||
internal sealed record BomIndexDocument(string ImageDigest, DateTimeOffset GeneratedAt, ImmutableArray<BomIndexComponent> Components);
|
||||
|
||||
internal static class BomIndexReader
|
||||
{
|
||||
private const int HeaderMagicLength = 7;
|
||||
private static readonly byte[] Magic = Encoding.ASCII.GetBytes("BOMIDX1");
|
||||
|
||||
public static BomIndexDocument Read(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
Span<byte> magicBuffer = stackalloc byte[HeaderMagicLength];
|
||||
if (reader.Read(magicBuffer) != HeaderMagicLength || !magicBuffer.SequenceEqual(Magic))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid BOM index header magic.");
|
||||
}
|
||||
|
||||
var version = reader.ReadUInt16();
|
||||
if (version != 1)
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported BOM index version '{version}'.");
|
||||
}
|
||||
|
||||
var flags = reader.ReadUInt16();
|
||||
var hasEntrypoints = (flags & 0x1) == 1;
|
||||
|
||||
var digestLength = reader.ReadUInt16();
|
||||
var digestBytes = reader.ReadBytes(digestLength);
|
||||
var imageDigest = Encoding.UTF8.GetString(digestBytes);
|
||||
|
||||
var generatedAtMicros = reader.ReadInt64();
|
||||
var generatedAt = DateTimeOffset.FromUnixTimeMilliseconds(generatedAtMicros / 1000)
|
||||
.AddTicks((generatedAtMicros % 1000) * TimeSpan.TicksPerMillisecond / 1000);
|
||||
|
||||
var layerCount = checked((int)reader.ReadUInt32());
|
||||
var componentCount = checked((int)reader.ReadUInt32());
|
||||
var entrypointCount = checked((int)reader.ReadUInt32());
|
||||
|
||||
// Layer table (we only need to skip entries but validate length)
|
||||
for (var i = 0; i < layerCount; i++)
|
||||
{
|
||||
_ = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
var componentKeys = new string[componentCount];
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
componentKeys[i] = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var length = reader.ReadUInt32();
|
||||
if (length > 0)
|
||||
{
|
||||
var payload = reader.ReadBytes(checked((int)length));
|
||||
using var bitmapStream = new MemoryStream(payload, writable: false);
|
||||
_ = RoaringBitmap.Deserialize(bitmapStream);
|
||||
}
|
||||
}
|
||||
|
||||
var entrypointPresence = new bool[componentCount];
|
||||
if (hasEntrypoints && entrypointCount > 0)
|
||||
{
|
||||
// Entrypoint table (skip strings)
|
||||
for (var i = 0; i < entrypointCount; i++)
|
||||
{
|
||||
_ = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var length = reader.ReadUInt32();
|
||||
if (length == 0)
|
||||
{
|
||||
entrypointPresence[i] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var payload = reader.ReadBytes(checked((int)length));
|
||||
using var bitmapStream = new MemoryStream(payload, writable: false);
|
||||
var bitmap = RoaringBitmap.Deserialize(bitmapStream);
|
||||
entrypointPresence[i] = bitmap.Any();
|
||||
}
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<BomIndexComponent>(componentCount);
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var key = componentKeys[i];
|
||||
builder.Add(new BomIndexComponent(key, entrypointPresence[i]));
|
||||
}
|
||||
|
||||
return new BomIndexDocument(imageDigest, generatedAt, builder.MoveToImmutable());
|
||||
}
|
||||
|
||||
private static string ReadUtf8String(BinaryReader reader)
|
||||
{
|
||||
var length = reader.ReadUInt16();
|
||||
if (length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var bytes = reader.ReadBytes(length);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Collections.Special;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
|
||||
internal sealed record BomIndexComponent(string Key, bool UsedByEntrypoint);
|
||||
|
||||
internal sealed record BomIndexDocument(string ImageDigest, DateTimeOffset GeneratedAt, ImmutableArray<BomIndexComponent> Components);
|
||||
|
||||
internal static class BomIndexReader
|
||||
{
|
||||
private const int HeaderMagicLength = 7;
|
||||
private static readonly byte[] Magic = Encoding.ASCII.GetBytes("BOMIDX1");
|
||||
|
||||
public static BomIndexDocument Read(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
Span<byte> magicBuffer = stackalloc byte[HeaderMagicLength];
|
||||
if (reader.Read(magicBuffer) != HeaderMagicLength || !magicBuffer.SequenceEqual(Magic))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid BOM index header magic.");
|
||||
}
|
||||
|
||||
var version = reader.ReadUInt16();
|
||||
if (version != 1)
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported BOM index version '{version}'.");
|
||||
}
|
||||
|
||||
var flags = reader.ReadUInt16();
|
||||
var hasEntrypoints = (flags & 0x1) == 1;
|
||||
|
||||
var digestLength = reader.ReadUInt16();
|
||||
var digestBytes = reader.ReadBytes(digestLength);
|
||||
var imageDigest = Encoding.UTF8.GetString(digestBytes);
|
||||
|
||||
var generatedAtMicros = reader.ReadInt64();
|
||||
var generatedAt = DateTimeOffset.FromUnixTimeMilliseconds(generatedAtMicros / 1000)
|
||||
.AddTicks((generatedAtMicros % 1000) * TimeSpan.TicksPerMillisecond / 1000);
|
||||
|
||||
var layerCount = checked((int)reader.ReadUInt32());
|
||||
var componentCount = checked((int)reader.ReadUInt32());
|
||||
var entrypointCount = checked((int)reader.ReadUInt32());
|
||||
|
||||
// Layer table (we only need to skip entries but validate length)
|
||||
for (var i = 0; i < layerCount; i++)
|
||||
{
|
||||
_ = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
var componentKeys = new string[componentCount];
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
componentKeys[i] = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var length = reader.ReadUInt32();
|
||||
if (length > 0)
|
||||
{
|
||||
var payload = reader.ReadBytes(checked((int)length));
|
||||
using var bitmapStream = new MemoryStream(payload, writable: false);
|
||||
_ = RoaringBitmap.Deserialize(bitmapStream);
|
||||
}
|
||||
}
|
||||
|
||||
var entrypointPresence = new bool[componentCount];
|
||||
if (hasEntrypoints && entrypointCount > 0)
|
||||
{
|
||||
// Entrypoint table (skip strings)
|
||||
for (var i = 0; i < entrypointCount; i++)
|
||||
{
|
||||
_ = ReadUtf8String(reader);
|
||||
}
|
||||
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var length = reader.ReadUInt32();
|
||||
if (length == 0)
|
||||
{
|
||||
entrypointPresence[i] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var payload = reader.ReadBytes(checked((int)length));
|
||||
using var bitmapStream = new MemoryStream(payload, writable: false);
|
||||
var bitmap = RoaringBitmap.Deserialize(bitmapStream);
|
||||
entrypointPresence[i] = bitmap.Any();
|
||||
}
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<BomIndexComponent>(componentCount);
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var key = componentKeys[i];
|
||||
builder.Add(new BomIndexComponent(key, entrypointPresence[i]));
|
||||
}
|
||||
|
||||
return new BomIndexDocument(imageDigest, generatedAt, builder.MoveToImmutable());
|
||||
}
|
||||
|
||||
private static string ReadUtf8String(BinaryReader reader)
|
||||
{
|
||||
var length = reader.ReadUInt16();
|
||||
if (length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var bytes = reader.ReadBytes(length);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a BOM-Index ingestion payload for the scheduler impact index.
|
||||
/// </summary>
|
||||
public sealed record ImpactIndexIngestionRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
public required string Registry { get; init; }
|
||||
|
||||
public required string Repository { get; init; }
|
||||
|
||||
public ImmutableArray<string> Namespaces { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public ImmutableSortedDictionary<string, string> Labels { get; init; } = ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public required Stream BomIndexStream { get; init; }
|
||||
= Stream.Null;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a BOM-Index ingestion payload for the scheduler impact index.
|
||||
/// </summary>
|
||||
public sealed record ImpactIndexIngestionRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
public required string Registry { get; init; }
|
||||
|
||||
public required string Repository { get; init; }
|
||||
|
||||
public ImmutableArray<string> Namespaces { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public ImmutableSortedDictionary<string, string> Labels { get; init; } = ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public required Stream BomIndexStream { get; init; }
|
||||
= Stream.Null;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Collections.Special;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Roaring bitmap-backed implementation of the scheduler impact index.
|
||||
/// </summary>
|
||||
public sealed class RoaringImpactIndex : IImpactIndex
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
private readonly Dictionary<string, int> _imageIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<int, ImpactImageRecord> _images = new();
|
||||
private readonly Dictionary<string, RoaringBitmap> _containsByPurl = new(StringComparer.OrdinalIgnoreCase);
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Collections.Special;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Roaring bitmap-backed implementation of the scheduler impact index.
|
||||
/// </summary>
|
||||
public sealed class RoaringImpactIndex : IImpactIndex
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
private readonly Dictionary<string, int> _imageIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<int, ImpactImageRecord> _images = new();
|
||||
private readonly Dictionary<string, RoaringBitmap> _containsByPurl = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, RoaringBitmap> _usedByEntrypointByPurl = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ILogger<RoaringImpactIndex> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private string? _snapshotId;
|
||||
|
||||
|
||||
public RoaringImpactIndex(ILogger<RoaringImpactIndex> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -58,103 +58,103 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task IngestAsync(ImpactIndexIngestionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BomIndexStream);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await request.BomIndexStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
buffer.Position = 0;
|
||||
|
||||
var document = BomIndexReader.Read(buffer);
|
||||
if (!string.Equals(document.ImageDigest, request.ImageDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BOM-Index digest mismatch. Header '{document.ImageDigest}', request '{request.ImageDigest}'.");
|
||||
}
|
||||
|
||||
var tenantId = request.TenantId ?? throw new ArgumentNullException(nameof(request.TenantId));
|
||||
var registry = request.Registry ?? throw new ArgumentNullException(nameof(request.Registry));
|
||||
var repository = request.Repository ?? throw new ArgumentNullException(nameof(request.Repository));
|
||||
|
||||
var namespaces = request.Namespaces.IsDefault ? ImmutableArray<string>.Empty : request.Namespaces;
|
||||
var tags = request.Tags.IsDefault ? ImmutableArray<string>.Empty : request.Tags;
|
||||
var labels = request.Labels.Count == 0
|
||||
? ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)
|
||||
: request.Labels;
|
||||
|
||||
var componentKeys = document.Components
|
||||
.Select(component => component.Key)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
var entrypointComponents = document.Components
|
||||
.Where(component => component.UsedByEntrypoint)
|
||||
.Select(component => component.Key)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var imageId = EnsureImageId(request.ImageDigest);
|
||||
|
||||
if (_images.TryGetValue(imageId, out var existing))
|
||||
{
|
||||
RemoveImageComponents(existing);
|
||||
}
|
||||
|
||||
var metadata = new ImpactImageRecord(
|
||||
imageId,
|
||||
tenantId,
|
||||
request.ImageDigest,
|
||||
registry,
|
||||
repository,
|
||||
namespaces,
|
||||
tags,
|
||||
labels,
|
||||
document.GeneratedAt,
|
||||
componentKeys,
|
||||
entrypointComponents);
|
||||
|
||||
_images[imageId] = metadata;
|
||||
_imageIds[request.ImageDigest] = imageId;
|
||||
|
||||
foreach (var key in componentKeys)
|
||||
{
|
||||
var bitmap = _containsByPurl.GetValueOrDefault(key);
|
||||
_containsByPurl[key] = AddImageToBitmap(bitmap, imageId);
|
||||
}
|
||||
|
||||
foreach (var key in entrypointComponents)
|
||||
{
|
||||
var bitmap = _usedByEntrypointByPurl.GetValueOrDefault(key);
|
||||
_usedByEntrypointByPurl[key] = AddImageToBitmap(bitmap, imageId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImpactIndex ingested BOM-Index for {Digest} ({TenantId}/{Repository}). Components={ComponentCount} EntrypointComponents={EntrypointCount}",
|
||||
request.ImageDigest,
|
||||
tenantId,
|
||||
repository,
|
||||
componentKeys.Length,
|
||||
entrypointComponents.Length);
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(
|
||||
IEnumerable<string> purls,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(ResolveByPurlsCore(purls, usageOnly, selector));
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
|
||||
IEnumerable<string> vulnerabilityIds,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
|
||||
|
||||
public async Task IngestAsync(ImpactIndexIngestionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BomIndexStream);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await request.BomIndexStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
buffer.Position = 0;
|
||||
|
||||
var document = BomIndexReader.Read(buffer);
|
||||
if (!string.Equals(document.ImageDigest, request.ImageDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BOM-Index digest mismatch. Header '{document.ImageDigest}', request '{request.ImageDigest}'.");
|
||||
}
|
||||
|
||||
var tenantId = request.TenantId ?? throw new ArgumentNullException(nameof(request.TenantId));
|
||||
var registry = request.Registry ?? throw new ArgumentNullException(nameof(request.Registry));
|
||||
var repository = request.Repository ?? throw new ArgumentNullException(nameof(request.Repository));
|
||||
|
||||
var namespaces = request.Namespaces.IsDefault ? ImmutableArray<string>.Empty : request.Namespaces;
|
||||
var tags = request.Tags.IsDefault ? ImmutableArray<string>.Empty : request.Tags;
|
||||
var labels = request.Labels.Count == 0
|
||||
? ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)
|
||||
: request.Labels;
|
||||
|
||||
var componentKeys = document.Components
|
||||
.Select(component => component.Key)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
var entrypointComponents = document.Components
|
||||
.Where(component => component.UsedByEntrypoint)
|
||||
.Select(component => component.Key)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var imageId = EnsureImageId(request.ImageDigest);
|
||||
|
||||
if (_images.TryGetValue(imageId, out var existing))
|
||||
{
|
||||
RemoveImageComponents(existing);
|
||||
}
|
||||
|
||||
var metadata = new ImpactImageRecord(
|
||||
imageId,
|
||||
tenantId,
|
||||
request.ImageDigest,
|
||||
registry,
|
||||
repository,
|
||||
namespaces,
|
||||
tags,
|
||||
labels,
|
||||
document.GeneratedAt,
|
||||
componentKeys,
|
||||
entrypointComponents);
|
||||
|
||||
_images[imageId] = metadata;
|
||||
_imageIds[request.ImageDigest] = imageId;
|
||||
|
||||
foreach (var key in componentKeys)
|
||||
{
|
||||
var bitmap = _containsByPurl.GetValueOrDefault(key);
|
||||
_containsByPurl[key] = AddImageToBitmap(bitmap, imageId);
|
||||
}
|
||||
|
||||
foreach (var key in entrypointComponents)
|
||||
{
|
||||
var bitmap = _usedByEntrypointByPurl.GetValueOrDefault(key);
|
||||
_usedByEntrypointByPurl[key] = AddImageToBitmap(bitmap, imageId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"ImpactIndex ingested BOM-Index for {Digest} ({TenantId}/{Repository}). Components={ComponentCount} EntrypointComponents={EntrypointCount}",
|
||||
request.ImageDigest,
|
||||
tenantId,
|
||||
repository,
|
||||
componentKeys.Length,
|
||||
entrypointComponents.Length);
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(
|
||||
IEnumerable<string> purls,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(ResolveByPurlsCore(purls, usageOnly, selector));
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
|
||||
IEnumerable<string> vulnerabilityIds,
|
||||
bool usageOnly,
|
||||
Selector selector,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
|
||||
public ValueTask<ImpactSet> ResolveAllAsync(
|
||||
Selector selector,
|
||||
bool usageOnly,
|
||||
@@ -257,102 +257,102 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ImpactSet ResolveByPurlsCore(IEnumerable<string> purls, bool usageOnly, Selector selector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(purls);
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
var normalized = purls
|
||||
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
|
||||
.Select(static purl => purl.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return CreateEmptyImpactSet(selector, usageOnly);
|
||||
}
|
||||
|
||||
RoaringBitmap imageIds;
|
||||
lock (_gate)
|
||||
{
|
||||
imageIds = RoaringBitmap.Create(Array.Empty<int>());
|
||||
foreach (var purl in normalized)
|
||||
{
|
||||
if (_containsByPurl.TryGetValue(purl, out var bitmap))
|
||||
{
|
||||
imageIds = imageIds | bitmap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildImpactSet(imageIds, selector, usageOnly);
|
||||
}
|
||||
|
||||
private ImpactSet ResolveAllCore(Selector selector, bool usageOnly)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
RoaringBitmap bitmap;
|
||||
lock (_gate)
|
||||
{
|
||||
var ids = _images.Keys.OrderBy(id => id).ToArray();
|
||||
bitmap = RoaringBitmap.Create(ids);
|
||||
}
|
||||
|
||||
return BuildImpactSet(bitmap, selector, usageOnly);
|
||||
}
|
||||
|
||||
private ImpactSet BuildImpactSet(RoaringBitmap imageIds, Selector selector, bool usageOnly)
|
||||
{
|
||||
var images = new List<ImpactImage>();
|
||||
var latestGeneratedAt = DateTimeOffset.MinValue;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var imageId in imageIds)
|
||||
{
|
||||
if (!_images.TryGetValue(imageId, out var metadata))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ImageMatchesSelector(metadata, selector))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (usageOnly && metadata.EntrypointComponents.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metadata.GeneratedAt > latestGeneratedAt)
|
||||
{
|
||||
latestGeneratedAt = metadata.GeneratedAt;
|
||||
}
|
||||
|
||||
images.Add(new ImpactImage(
|
||||
metadata.Digest,
|
||||
metadata.Registry,
|
||||
metadata.Repository,
|
||||
metadata.Namespaces,
|
||||
metadata.Tags,
|
||||
metadata.EntrypointComponents.Length > 0,
|
||||
metadata.Labels));
|
||||
}
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
return CreateEmptyImpactSet(selector, usageOnly);
|
||||
}
|
||||
|
||||
images.Sort(static (left, right) => string.Compare(left.ImageDigest, right.ImageDigest, StringComparison.Ordinal));
|
||||
|
||||
var generatedAt = latestGeneratedAt == DateTimeOffset.MinValue ? _timeProvider.GetUtcNow() : latestGeneratedAt;
|
||||
|
||||
|
||||
private ImpactSet ResolveByPurlsCore(IEnumerable<string> purls, bool usageOnly, Selector selector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(purls);
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
var normalized = purls
|
||||
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
|
||||
.Select(static purl => purl.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return CreateEmptyImpactSet(selector, usageOnly);
|
||||
}
|
||||
|
||||
RoaringBitmap imageIds;
|
||||
lock (_gate)
|
||||
{
|
||||
imageIds = RoaringBitmap.Create(Array.Empty<int>());
|
||||
foreach (var purl in normalized)
|
||||
{
|
||||
if (_containsByPurl.TryGetValue(purl, out var bitmap))
|
||||
{
|
||||
imageIds = imageIds | bitmap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildImpactSet(imageIds, selector, usageOnly);
|
||||
}
|
||||
|
||||
private ImpactSet ResolveAllCore(Selector selector, bool usageOnly)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
RoaringBitmap bitmap;
|
||||
lock (_gate)
|
||||
{
|
||||
var ids = _images.Keys.OrderBy(id => id).ToArray();
|
||||
bitmap = RoaringBitmap.Create(ids);
|
||||
}
|
||||
|
||||
return BuildImpactSet(bitmap, selector, usageOnly);
|
||||
}
|
||||
|
||||
private ImpactSet BuildImpactSet(RoaringBitmap imageIds, Selector selector, bool usageOnly)
|
||||
{
|
||||
var images = new List<ImpactImage>();
|
||||
var latestGeneratedAt = DateTimeOffset.MinValue;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var imageId in imageIds)
|
||||
{
|
||||
if (!_images.TryGetValue(imageId, out var metadata))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ImageMatchesSelector(metadata, selector))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (usageOnly && metadata.EntrypointComponents.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metadata.GeneratedAt > latestGeneratedAt)
|
||||
{
|
||||
latestGeneratedAt = metadata.GeneratedAt;
|
||||
}
|
||||
|
||||
images.Add(new ImpactImage(
|
||||
metadata.Digest,
|
||||
metadata.Registry,
|
||||
metadata.Repository,
|
||||
metadata.Namespaces,
|
||||
metadata.Tags,
|
||||
metadata.EntrypointComponents.Length > 0,
|
||||
metadata.Labels));
|
||||
}
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
return CreateEmptyImpactSet(selector, usageOnly);
|
||||
}
|
||||
|
||||
images.Sort(static (left, right) => string.Compare(left.ImageDigest, right.ImageDigest, StringComparison.Ordinal));
|
||||
|
||||
var generatedAt = latestGeneratedAt == DateTimeOffset.MinValue ? _timeProvider.GetUtcNow() : latestGeneratedAt;
|
||||
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
images.ToImmutableArray(),
|
||||
@@ -362,9 +362,9 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
snapshotId: _snapshotId,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
private ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
|
||||
{
|
||||
|
||||
private ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
|
||||
{
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
ImmutableArray<ImpactImage>.Empty,
|
||||
@@ -374,167 +374,167 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
snapshotId: _snapshotId,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
private static bool ImageMatchesSelector(ImpactImageRecord image, Selector selector)
|
||||
{
|
||||
if (selector.TenantId is not null && !string.Equals(selector.TenantId, image.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesScope(image, selector))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selector.Digests.Length > 0 && !selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selector.Repositories.Length > 0)
|
||||
{
|
||||
var repoMatch = selector.Repositories.Any(repo =>
|
||||
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
|
||||
if (!repoMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.Namespaces.Length > 0)
|
||||
{
|
||||
if (image.Namespaces.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var namespaceMatch = selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase));
|
||||
if (!namespaceMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.IncludeTags.Length > 0)
|
||||
{
|
||||
if (image.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tagMatch = selector.IncludeTags.Any(pattern => image.Tags.Any(tag => MatchesTagPattern(tag, pattern)));
|
||||
if (!tagMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.Labels.Length > 0)
|
||||
{
|
||||
if (image.Labels.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var label in selector.Labels)
|
||||
{
|
||||
if (!image.Labels.TryGetValue(label.Key, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (label.Values.Length > 0 && !label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RemoveImageComponents(ImpactImageRecord record)
|
||||
{
|
||||
foreach (var key in record.Components)
|
||||
{
|
||||
if (_containsByPurl.TryGetValue(key, out var bitmap))
|
||||
{
|
||||
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
|
||||
if (updated is null)
|
||||
{
|
||||
_containsByPurl.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_containsByPurl[key] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in record.EntrypointComponents)
|
||||
{
|
||||
if (_usedByEntrypointByPurl.TryGetValue(key, out var bitmap))
|
||||
{
|
||||
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
|
||||
if (updated is null)
|
||||
{
|
||||
_usedByEntrypointByPurl.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_usedByEntrypointByPurl[key] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RoaringBitmap AddImageToBitmap(RoaringBitmap? bitmap, int imageId)
|
||||
{
|
||||
if (bitmap is null)
|
||||
{
|
||||
return RoaringBitmap.Create(new[] { imageId });
|
||||
}
|
||||
|
||||
if (bitmap.Any(id => id == imageId))
|
||||
{
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
var merged = bitmap
|
||||
.Concat(new[] { imageId })
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
|
||||
return RoaringBitmap.Create(merged);
|
||||
}
|
||||
|
||||
private static RoaringBitmap? RemoveImageFromBitmap(RoaringBitmap bitmap, int imageId)
|
||||
{
|
||||
var remaining = bitmap
|
||||
.Where(id => id != imageId)
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
if (remaining.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return RoaringBitmap.Create(remaining);
|
||||
}
|
||||
|
||||
|
||||
private static bool ImageMatchesSelector(ImpactImageRecord image, Selector selector)
|
||||
{
|
||||
if (selector.TenantId is not null && !string.Equals(selector.TenantId, image.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesScope(image, selector))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selector.Digests.Length > 0 && !selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selector.Repositories.Length > 0)
|
||||
{
|
||||
var repoMatch = selector.Repositories.Any(repo =>
|
||||
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
|
||||
if (!repoMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.Namespaces.Length > 0)
|
||||
{
|
||||
if (image.Namespaces.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var namespaceMatch = selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase));
|
||||
if (!namespaceMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.IncludeTags.Length > 0)
|
||||
{
|
||||
if (image.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tagMatch = selector.IncludeTags.Any(pattern => image.Tags.Any(tag => MatchesTagPattern(tag, pattern)));
|
||||
if (!tagMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selector.Labels.Length > 0)
|
||||
{
|
||||
if (image.Labels.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var label in selector.Labels)
|
||||
{
|
||||
if (!image.Labels.TryGetValue(label.Key, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (label.Values.Length > 0 && !label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RemoveImageComponents(ImpactImageRecord record)
|
||||
{
|
||||
foreach (var key in record.Components)
|
||||
{
|
||||
if (_containsByPurl.TryGetValue(key, out var bitmap))
|
||||
{
|
||||
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
|
||||
if (updated is null)
|
||||
{
|
||||
_containsByPurl.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_containsByPurl[key] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in record.EntrypointComponents)
|
||||
{
|
||||
if (_usedByEntrypointByPurl.TryGetValue(key, out var bitmap))
|
||||
{
|
||||
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
|
||||
if (updated is null)
|
||||
{
|
||||
_usedByEntrypointByPurl.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_usedByEntrypointByPurl[key] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RoaringBitmap AddImageToBitmap(RoaringBitmap? bitmap, int imageId)
|
||||
{
|
||||
if (bitmap is null)
|
||||
{
|
||||
return RoaringBitmap.Create(new[] { imageId });
|
||||
}
|
||||
|
||||
if (bitmap.Any(id => id == imageId))
|
||||
{
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
var merged = bitmap
|
||||
.Concat(new[] { imageId })
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
|
||||
return RoaringBitmap.Create(merged);
|
||||
}
|
||||
|
||||
private static RoaringBitmap? RemoveImageFromBitmap(RoaringBitmap bitmap, int imageId)
|
||||
{
|
||||
var remaining = bitmap
|
||||
.Where(id => id != imageId)
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
if (remaining.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return RoaringBitmap.Create(remaining);
|
||||
}
|
||||
|
||||
private static bool MatchesScope(ImpactImageRecord image, Selector selector)
|
||||
{
|
||||
return selector.Scope switch
|
||||
{
|
||||
SelectorScope.AllImages => true,
|
||||
SelectorScope.ByDigest => selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
|
||||
SelectorScope.ByRepository => selector.Repositories.Any(repo =>
|
||||
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
|
||||
SelectorScope.ByNamespace => !image.Namespaces.IsDefaultOrEmpty && selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase)),
|
||||
SelectorScope.ByDigest => selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
|
||||
SelectorScope.ByRepository => selector.Repositories.Any(repo =>
|
||||
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
|
||||
SelectorScope.ByNamespace => !image.Namespaces.IsDefaultOrEmpty && selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase)),
|
||||
SelectorScope.ByLabels => selector.Labels.All(label =>
|
||||
image.Labels.TryGetValue(label.Key, out var value) &&
|
||||
(label.Values.Length == 0 || label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))),
|
||||
@@ -573,63 +573,63 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return "snap-" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool MatchesTagPattern(string tag, string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pattern.Contains('*') && !pattern.Contains('?'))
|
||||
{
|
||||
return string.Equals(tag, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var escaped = Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".");
|
||||
return Regex.IsMatch(tag, $"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private int EnsureImageId(string digest)
|
||||
{
|
||||
if (_imageIds.TryGetValue(digest, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var candidate = ComputeDeterministicId(digest);
|
||||
while (_images.ContainsKey(candidate))
|
||||
{
|
||||
candidate = (candidate + 1) & int.MaxValue;
|
||||
if (candidate == 0)
|
||||
{
|
||||
candidate = 1;
|
||||
}
|
||||
}
|
||||
|
||||
_imageIds[digest] = candidate;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static int ComputeDeterministicId(string digest)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(digest));
|
||||
for (var offset = 0; offset <= bytes.Length - sizeof(int); offset += sizeof(int))
|
||||
{
|
||||
var value = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, sizeof(int))) & int.MaxValue;
|
||||
if (value != 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return digest.GetHashCode(StringComparison.OrdinalIgnoreCase) & int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesTagPattern(string tag, string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pattern.Contains('*') && !pattern.Contains('?'))
|
||||
{
|
||||
return string.Equals(tag, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var escaped = Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".");
|
||||
return Regex.IsMatch(tag, $"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private int EnsureImageId(string digest)
|
||||
{
|
||||
if (_imageIds.TryGetValue(digest, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var candidate = ComputeDeterministicId(digest);
|
||||
while (_images.ContainsKey(candidate))
|
||||
{
|
||||
candidate = (candidate + 1) & int.MaxValue;
|
||||
if (candidate == 0)
|
||||
{
|
||||
candidate = 1;
|
||||
}
|
||||
}
|
||||
|
||||
_imageIds[digest] = candidate;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static int ComputeDeterministicId(string digest)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(digest));
|
||||
for (var offset = 0; offset <= bytes.Length - sizeof(int); offset += sizeof(int))
|
||||
{
|
||||
var value = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, sizeof(int))) & int.MaxValue;
|
||||
if (value != 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return digest.GetHashCode(StringComparison.OrdinalIgnoreCase) & int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.ImpactIndex")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.ImpactIndex")]
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Audit log entry capturing schedule/run lifecycle events.
|
||||
/// </summary>
|
||||
public sealed record AuditRecord
|
||||
{
|
||||
public AuditRecord(
|
||||
string id,
|
||||
string tenantId,
|
||||
string category,
|
||||
string action,
|
||||
DateTimeOffset occurredAt,
|
||||
AuditActor actor,
|
||||
string? entityId = null,
|
||||
string? scheduleId = null,
|
||||
string? runId = null,
|
||||
string? correlationId = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? message = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
Validation.EnsureSimpleIdentifier(category, nameof(category)),
|
||||
Validation.EnsureSimpleIdentifier(action, nameof(action)),
|
||||
Validation.NormalizeTimestamp(occurredAt),
|
||||
actor,
|
||||
Validation.TrimToNull(entityId),
|
||||
Validation.TrimToNull(scheduleId),
|
||||
Validation.TrimToNull(runId),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
Validation.TrimToNull(message))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public AuditRecord(
|
||||
string id,
|
||||
string tenantId,
|
||||
string category,
|
||||
string action,
|
||||
DateTimeOffset occurredAt,
|
||||
AuditActor actor,
|
||||
string? entityId,
|
||||
string? scheduleId,
|
||||
string? runId,
|
||||
string? correlationId,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? message)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Category = Validation.EnsureSimpleIdentifier(category, nameof(category));
|
||||
Action = Validation.EnsureSimpleIdentifier(action, nameof(action));
|
||||
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
|
||||
Actor = actor ?? throw new ArgumentNullException(nameof(actor));
|
||||
EntityId = Validation.TrimToNull(entityId);
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
RunId = Validation.TrimToNull(runId);
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
Message = Validation.TrimToNull(message);
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public string Action { get; }
|
||||
|
||||
public DateTimeOffset OccurredAt { get; }
|
||||
|
||||
public AuditActor Actor { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? EntityId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RunId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Message { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor associated with an audit entry.
|
||||
/// </summary>
|
||||
public sealed record AuditActor
|
||||
{
|
||||
public AuditActor(string actorId, string displayName, string kind)
|
||||
{
|
||||
ActorId = Validation.EnsureSimpleIdentifier(actorId, nameof(actorId));
|
||||
DisplayName = Validation.EnsureName(displayName, nameof(displayName));
|
||||
Kind = Validation.EnsureSimpleIdentifier(kind, nameof(kind));
|
||||
}
|
||||
|
||||
public string ActorId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Audit log entry capturing schedule/run lifecycle events.
|
||||
/// </summary>
|
||||
public sealed record AuditRecord
|
||||
{
|
||||
public AuditRecord(
|
||||
string id,
|
||||
string tenantId,
|
||||
string category,
|
||||
string action,
|
||||
DateTimeOffset occurredAt,
|
||||
AuditActor actor,
|
||||
string? entityId = null,
|
||||
string? scheduleId = null,
|
||||
string? runId = null,
|
||||
string? correlationId = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? message = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
Validation.EnsureSimpleIdentifier(category, nameof(category)),
|
||||
Validation.EnsureSimpleIdentifier(action, nameof(action)),
|
||||
Validation.NormalizeTimestamp(occurredAt),
|
||||
actor,
|
||||
Validation.TrimToNull(entityId),
|
||||
Validation.TrimToNull(scheduleId),
|
||||
Validation.TrimToNull(runId),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
Validation.TrimToNull(message))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public AuditRecord(
|
||||
string id,
|
||||
string tenantId,
|
||||
string category,
|
||||
string action,
|
||||
DateTimeOffset occurredAt,
|
||||
AuditActor actor,
|
||||
string? entityId,
|
||||
string? scheduleId,
|
||||
string? runId,
|
||||
string? correlationId,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? message)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Category = Validation.EnsureSimpleIdentifier(category, nameof(category));
|
||||
Action = Validation.EnsureSimpleIdentifier(action, nameof(action));
|
||||
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
|
||||
Actor = actor ?? throw new ArgumentNullException(nameof(actor));
|
||||
EntityId = Validation.TrimToNull(entityId);
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
RunId = Validation.TrimToNull(runId);
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
Message = Validation.TrimToNull(message);
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public string Action { get; }
|
||||
|
||||
public DateTimeOffset OccurredAt { get; }
|
||||
|
||||
public AuditActor Actor { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? EntityId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RunId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Message { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor associated with an audit entry.
|
||||
/// </summary>
|
||||
public sealed record AuditActor
|
||||
{
|
||||
public AuditActor(string actorId, string displayName, string kind)
|
||||
{
|
||||
ActorId = Validation.EnsureSimpleIdentifier(actorId, nameof(actorId));
|
||||
DisplayName = Validation.EnsureName(displayName, nameof(displayName));
|
||||
Kind = Validation.EnsureSimpleIdentifier(kind, nameof(kind));
|
||||
}
|
||||
|
||||
public string ActorId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string Kind { get; }
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
|
||||
{
|
||||
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
|
||||
{
|
||||
[ScheduleMode.AnalysisOnly] = "analysis-only",
|
||||
[ScheduleMode.ContentRefresh] = "content-refresh",
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
|
||||
{
|
||||
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
|
||||
{
|
||||
[SelectorScope.AllImages] = "all-images",
|
||||
[SelectorScope.ByNamespace] = "by-namespace",
|
||||
[SelectorScope.ByRepository] = "by-repo",
|
||||
[SelectorScope.ByDigest] = "by-digest",
|
||||
[SelectorScope.ByLabels] = "by-labels",
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
|
||||
{
|
||||
}
|
||||
|
||||
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
|
||||
{
|
||||
}
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
|
||||
{
|
||||
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
|
||||
{
|
||||
[ScheduleMode.AnalysisOnly] = "analysis-only",
|
||||
[ScheduleMode.ContentRefresh] = "content-refresh",
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
|
||||
{
|
||||
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
|
||||
{
|
||||
[SelectorScope.AllImages] = "all-images",
|
||||
[SelectorScope.ByNamespace] = "by-namespace",
|
||||
[SelectorScope.ByRepository] = "by-repo",
|
||||
[SelectorScope.ByDigest] = "by-digest",
|
||||
[SelectorScope.ByLabels] = "by-labels",
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
|
||||
{
|
||||
}
|
||||
|
||||
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
|
||||
{
|
||||
}
|
||||
|
||||
internal sealed class SeverityRankConverter : LowerCaseEnumConverter<SeverityRank>
|
||||
{
|
||||
protected override string ConvertToString(SeverityRank value)
|
||||
=> value switch
|
||||
{
|
||||
SeverityRank.None => "none",
|
||||
SeverityRank.Info => "info",
|
||||
SeverityRank.Low => "low",
|
||||
SeverityRank.Medium => "medium",
|
||||
SeverityRank.High => "high",
|
||||
SeverityRank.Critical => "critical",
|
||||
SeverityRank.None => "none",
|
||||
SeverityRank.Info => "info",
|
||||
SeverityRank.Low => "low",
|
||||
SeverityRank.Medium => "medium",
|
||||
SeverityRank.High => "high",
|
||||
SeverityRank.Critical => "critical",
|
||||
SeverityRank.Unknown => "unknown",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
|
||||
};
|
||||
@@ -144,58 +144,58 @@ internal abstract class HyphenatedEnumConverter<TEnum> : JsonConverter<TEnum>
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
private readonly Dictionary<string, TEnum> _reverse;
|
||||
|
||||
protected HyphenatedEnumConverter()
|
||||
{
|
||||
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
|
||||
|
||||
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (value is not null && _reverse.TryGetValue(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
|
||||
{
|
||||
if (Map.TryGetValue(value, out var text))
|
||||
{
|
||||
writer.WriteStringValue(text);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
|
||||
}
|
||||
}
|
||||
|
||||
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
private static readonly Dictionary<string, TEnum> Reverse = Enum
|
||||
.GetValues<TEnum>()
|
||||
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (value is not null && Reverse.TryGetValue(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(ConvertToString(value));
|
||||
|
||||
protected virtual string ConvertToString(TEnum value)
|
||||
=> value.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
protected HyphenatedEnumConverter()
|
||||
{
|
||||
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
|
||||
|
||||
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (value is not null && _reverse.TryGetValue(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
|
||||
{
|
||||
if (Map.TryGetValue(value, out var text))
|
||||
{
|
||||
writer.WriteStringValue(text);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
|
||||
}
|
||||
}
|
||||
|
||||
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
private static readonly Dictionary<string, TEnum> Reverse = Enum
|
||||
.GetValues<TEnum>()
|
||||
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
if (value is not null && Reverse.TryGetValue(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(ConvertToString(value));
|
||||
|
||||
protected virtual string ConvertToString(TEnum value)
|
||||
=> value.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Job instructing Cartographer to materialize a graph snapshot for an SBOM version.
|
||||
/// </summary>
|
||||
public sealed record GraphBuildJob
|
||||
{
|
||||
public GraphBuildJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string sbomId,
|
||||
string sbomVersionId,
|
||||
string sbomDigest,
|
||||
GraphJobStatus status,
|
||||
GraphBuildJobTrigger trigger,
|
||||
DateTimeOffset createdAt,
|
||||
string? graphSnapshotId = null,
|
||||
int attempts = 0,
|
||||
string? cartographerJobId = null,
|
||||
string? correlationId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? completedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
sbomId,
|
||||
sbomVersionId,
|
||||
sbomDigest,
|
||||
Validation.TrimToNull(graphSnapshotId),
|
||||
status,
|
||||
trigger,
|
||||
Validation.EnsureNonNegative(attempts, nameof(attempts)),
|
||||
Validation.TrimToNull(cartographerJobId),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(completedAt),
|
||||
Validation.TrimToNull(error),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public GraphBuildJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string sbomId,
|
||||
string sbomVersionId,
|
||||
string sbomDigest,
|
||||
string? graphSnapshotId,
|
||||
GraphJobStatus status,
|
||||
GraphBuildJobTrigger trigger,
|
||||
int attempts,
|
||||
string? cartographerJobId,
|
||||
string? correlationId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? completedAt,
|
||||
string? error,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
SbomId = Validation.EnsureId(sbomId, nameof(sbomId));
|
||||
SbomVersionId = Validation.EnsureId(sbomVersionId, nameof(sbomVersionId));
|
||||
SbomDigest = Validation.EnsureDigestFormat(sbomDigest, nameof(sbomDigest));
|
||||
GraphSnapshotId = Validation.TrimToNull(graphSnapshotId);
|
||||
Status = status;
|
||||
Trigger = trigger;
|
||||
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
|
||||
CartographerJobId = Validation.TrimToNull(cartographerJobId);
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
CompletedAt = Validation.NormalizeTimestamp(completedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureGraphBuildJob(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string SbomId { get; }
|
||||
|
||||
public string SbomVersionId { get; }
|
||||
|
||||
public string SbomDigest { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public GraphJobStatus Status { get; init; }
|
||||
|
||||
public GraphBuildJobTrigger Trigger { get; }
|
||||
|
||||
public int Attempts { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CartographerJobId { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Job instructing Cartographer to materialize a graph snapshot for an SBOM version.
|
||||
/// </summary>
|
||||
public sealed record GraphBuildJob
|
||||
{
|
||||
public GraphBuildJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string sbomId,
|
||||
string sbomVersionId,
|
||||
string sbomDigest,
|
||||
GraphJobStatus status,
|
||||
GraphBuildJobTrigger trigger,
|
||||
DateTimeOffset createdAt,
|
||||
string? graphSnapshotId = null,
|
||||
int attempts = 0,
|
||||
string? cartographerJobId = null,
|
||||
string? correlationId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? completedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
sbomId,
|
||||
sbomVersionId,
|
||||
sbomDigest,
|
||||
Validation.TrimToNull(graphSnapshotId),
|
||||
status,
|
||||
trigger,
|
||||
Validation.EnsureNonNegative(attempts, nameof(attempts)),
|
||||
Validation.TrimToNull(cartographerJobId),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(completedAt),
|
||||
Validation.TrimToNull(error),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public GraphBuildJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string sbomId,
|
||||
string sbomVersionId,
|
||||
string sbomDigest,
|
||||
string? graphSnapshotId,
|
||||
GraphJobStatus status,
|
||||
GraphBuildJobTrigger trigger,
|
||||
int attempts,
|
||||
string? cartographerJobId,
|
||||
string? correlationId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? completedAt,
|
||||
string? error,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
SbomId = Validation.EnsureId(sbomId, nameof(sbomId));
|
||||
SbomVersionId = Validation.EnsureId(sbomVersionId, nameof(sbomVersionId));
|
||||
SbomDigest = Validation.EnsureDigestFormat(sbomDigest, nameof(sbomDigest));
|
||||
GraphSnapshotId = Validation.TrimToNull(graphSnapshotId);
|
||||
Status = status;
|
||||
Trigger = trigger;
|
||||
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
|
||||
CartographerJobId = Validation.TrimToNull(cartographerJobId);
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
CompletedAt = Validation.NormalizeTimestamp(completedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureGraphBuildJob(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string SbomId { get; }
|
||||
|
||||
public string SbomVersionId { get; }
|
||||
|
||||
public string SbomDigest { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public GraphJobStatus Status { get; init; }
|
||||
|
||||
public GraphBuildJobTrigger Trigger { get; }
|
||||
|
||||
public int Attempts { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CartographerJobId { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
@@ -1,241 +1,241 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates allowed status transitions and invariants for graph jobs.
|
||||
/// </summary>
|
||||
public static class GraphJobStateMachine
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<GraphJobStatus, GraphJobStatus[]> Adjacency = new Dictionary<GraphJobStatus, GraphJobStatus[]>
|
||||
{
|
||||
[GraphJobStatus.Pending] = new[] { GraphJobStatus.Pending, GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Queued] = new[] { GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Running] = new[] { GraphJobStatus.Running, GraphJobStatus.Completed, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Completed] = new[] { GraphJobStatus.Completed },
|
||||
[GraphJobStatus.Failed] = new[] { GraphJobStatus.Failed },
|
||||
[GraphJobStatus.Cancelled] = new[] { GraphJobStatus.Cancelled },
|
||||
};
|
||||
|
||||
public static bool CanTransition(GraphJobStatus from, GraphJobStatus to)
|
||||
{
|
||||
if (!Adjacency.TryGetValue(from, out var allowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowed.Contains(to);
|
||||
}
|
||||
|
||||
public static bool IsTerminal(GraphJobStatus status)
|
||||
=> status is GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled;
|
||||
|
||||
public static GraphBuildJob EnsureTransition(
|
||||
GraphBuildJob job,
|
||||
GraphJobStatus next,
|
||||
DateTimeOffset timestamp,
|
||||
int? attempts = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = job.Status;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Graph build job transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var nextAttempts = attempts ?? job.Attempts;
|
||||
if (nextAttempts < job.Attempts)
|
||||
{
|
||||
throw new InvalidOperationException("Graph job attempts cannot decrease.");
|
||||
}
|
||||
|
||||
var startedAt = job.StartedAt;
|
||||
var completedAt = job.CompletedAt;
|
||||
|
||||
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
completedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == GraphJobStatus.Failed)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
Status = next,
|
||||
Attempts = nextAttempts,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static GraphOverlayJob EnsureTransition(
|
||||
GraphOverlayJob job,
|
||||
GraphJobStatus next,
|
||||
DateTimeOffset timestamp,
|
||||
int? attempts = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = job.Status;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Graph overlay job transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var nextAttempts = attempts ?? job.Attempts;
|
||||
if (nextAttempts < job.Attempts)
|
||||
{
|
||||
throw new InvalidOperationException("Graph job attempts cannot decrease.");
|
||||
}
|
||||
|
||||
var startedAt = job.StartedAt;
|
||||
var completedAt = job.CompletedAt;
|
||||
|
||||
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
completedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == GraphJobStatus.Failed)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
Status = next,
|
||||
Attempts = nextAttempts,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static void Validate(GraphBuildJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (job.StartedAt is { } started && started < job.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (job.CompletedAt is { } completed)
|
||||
{
|
||||
if (job.StartedAt is { } start && completed < start)
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.CompletedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.CompletedAt set while status is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal graph build job states must include CompletedAt.");
|
||||
}
|
||||
|
||||
if (job.Status == GraphJobStatus.Failed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.Error must be populated when status is Failed.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.Error must be null for non-failed states.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Validate(GraphOverlayJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (job.StartedAt is { } started && started < job.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (job.CompletedAt is { } completed)
|
||||
{
|
||||
if (job.StartedAt is { } start && completed < start)
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.CompletedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.CompletedAt set while status is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal graph overlay job states must include CompletedAt.");
|
||||
}
|
||||
|
||||
if (job.Status == GraphJobStatus.Failed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.Error must be populated when status is Failed.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.Error must be null for non-failed states.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates allowed status transitions and invariants for graph jobs.
|
||||
/// </summary>
|
||||
public static class GraphJobStateMachine
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<GraphJobStatus, GraphJobStatus[]> Adjacency = new Dictionary<GraphJobStatus, GraphJobStatus[]>
|
||||
{
|
||||
[GraphJobStatus.Pending] = new[] { GraphJobStatus.Pending, GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Queued] = new[] { GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Running] = new[] { GraphJobStatus.Running, GraphJobStatus.Completed, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
|
||||
[GraphJobStatus.Completed] = new[] { GraphJobStatus.Completed },
|
||||
[GraphJobStatus.Failed] = new[] { GraphJobStatus.Failed },
|
||||
[GraphJobStatus.Cancelled] = new[] { GraphJobStatus.Cancelled },
|
||||
};
|
||||
|
||||
public static bool CanTransition(GraphJobStatus from, GraphJobStatus to)
|
||||
{
|
||||
if (!Adjacency.TryGetValue(from, out var allowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowed.Contains(to);
|
||||
}
|
||||
|
||||
public static bool IsTerminal(GraphJobStatus status)
|
||||
=> status is GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled;
|
||||
|
||||
public static GraphBuildJob EnsureTransition(
|
||||
GraphBuildJob job,
|
||||
GraphJobStatus next,
|
||||
DateTimeOffset timestamp,
|
||||
int? attempts = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = job.Status;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Graph build job transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var nextAttempts = attempts ?? job.Attempts;
|
||||
if (nextAttempts < job.Attempts)
|
||||
{
|
||||
throw new InvalidOperationException("Graph job attempts cannot decrease.");
|
||||
}
|
||||
|
||||
var startedAt = job.StartedAt;
|
||||
var completedAt = job.CompletedAt;
|
||||
|
||||
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
completedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == GraphJobStatus.Failed)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
Status = next,
|
||||
Attempts = nextAttempts,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static GraphOverlayJob EnsureTransition(
|
||||
GraphOverlayJob job,
|
||||
GraphJobStatus next,
|
||||
DateTimeOffset timestamp,
|
||||
int? attempts = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = job.Status;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Graph overlay job transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var nextAttempts = attempts ?? job.Attempts;
|
||||
if (nextAttempts < job.Attempts)
|
||||
{
|
||||
throw new InvalidOperationException("Graph job attempts cannot decrease.");
|
||||
}
|
||||
|
||||
var startedAt = job.StartedAt;
|
||||
var completedAt = job.CompletedAt;
|
||||
|
||||
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
completedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == GraphJobStatus.Failed)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
Status = next,
|
||||
Attempts = nextAttempts,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static void Validate(GraphBuildJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (job.StartedAt is { } started && started < job.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (job.CompletedAt is { } completed)
|
||||
{
|
||||
if (job.StartedAt is { } start && completed < start)
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.CompletedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.CompletedAt set while status is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal graph build job states must include CompletedAt.");
|
||||
}
|
||||
|
||||
if (job.Status == GraphJobStatus.Failed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.Error must be populated when status is Failed.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphBuildJob.Error must be null for non-failed states.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Validate(GraphOverlayJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (job.StartedAt is { } started && started < job.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (job.CompletedAt is { } completed)
|
||||
{
|
||||
if (job.StartedAt is { } start && completed < start)
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.CompletedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.CompletedAt set while status is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal graph overlay job states must include CompletedAt.");
|
||||
}
|
||||
|
||||
if (job.Status == GraphJobStatus.Failed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.Error must be populated when status is Failed.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(job.Error))
|
||||
{
|
||||
throw new InvalidOperationException("GraphOverlayJob.Error must be null for non-failed states.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Job that materializes or refreshes an overlay on top of an existing graph snapshot.
|
||||
/// </summary>
|
||||
public sealed record GraphOverlayJob
|
||||
{
|
||||
public GraphOverlayJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string graphSnapshotId,
|
||||
GraphOverlayKind overlayKind,
|
||||
string overlayKey,
|
||||
GraphJobStatus status,
|
||||
GraphOverlayJobTrigger trigger,
|
||||
DateTimeOffset createdAt,
|
||||
IEnumerable<string>? subjects = null,
|
||||
int attempts = 0,
|
||||
string? buildJobId = null,
|
||||
string? correlationId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? completedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
graphSnapshotId,
|
||||
Validation.TrimToNull(buildJobId),
|
||||
overlayKind,
|
||||
Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey)),
|
||||
Validation.NormalizeStringSet(subjects, nameof(subjects)),
|
||||
status,
|
||||
trigger,
|
||||
Validation.EnsureNonNegative(attempts, nameof(attempts)),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(completedAt),
|
||||
Validation.TrimToNull(error),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public GraphOverlayJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string graphSnapshotId,
|
||||
string? buildJobId,
|
||||
GraphOverlayKind overlayKind,
|
||||
string overlayKey,
|
||||
ImmutableArray<string> subjects,
|
||||
GraphJobStatus status,
|
||||
GraphOverlayJobTrigger trigger,
|
||||
int attempts,
|
||||
string? correlationId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? completedAt,
|
||||
string? error,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
GraphSnapshotId = Validation.EnsureId(graphSnapshotId, nameof(graphSnapshotId));
|
||||
BuildJobId = Validation.TrimToNull(buildJobId);
|
||||
OverlayKind = overlayKind;
|
||||
OverlayKey = Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey));
|
||||
Subjects = subjects.IsDefault ? ImmutableArray<string>.Empty : subjects;
|
||||
Status = status;
|
||||
Trigger = trigger;
|
||||
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
CompletedAt = Validation.NormalizeTimestamp(completedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureGraphOverlayJob(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string GraphSnapshotId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? BuildJobId { get; init; }
|
||||
|
||||
public GraphOverlayKind OverlayKind { get; }
|
||||
|
||||
public string OverlayKey { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Subjects { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public GraphJobStatus Status { get; init; }
|
||||
|
||||
public GraphOverlayJobTrigger Trigger { get; }
|
||||
|
||||
public int Attempts { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Job that materializes or refreshes an overlay on top of an existing graph snapshot.
|
||||
/// </summary>
|
||||
public sealed record GraphOverlayJob
|
||||
{
|
||||
public GraphOverlayJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string graphSnapshotId,
|
||||
GraphOverlayKind overlayKind,
|
||||
string overlayKey,
|
||||
GraphJobStatus status,
|
||||
GraphOverlayJobTrigger trigger,
|
||||
DateTimeOffset createdAt,
|
||||
IEnumerable<string>? subjects = null,
|
||||
int attempts = 0,
|
||||
string? buildJobId = null,
|
||||
string? correlationId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? completedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
graphSnapshotId,
|
||||
Validation.TrimToNull(buildJobId),
|
||||
overlayKind,
|
||||
Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey)),
|
||||
Validation.NormalizeStringSet(subjects, nameof(subjects)),
|
||||
status,
|
||||
trigger,
|
||||
Validation.EnsureNonNegative(attempts, nameof(attempts)),
|
||||
Validation.TrimToNull(correlationId),
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(completedAt),
|
||||
Validation.TrimToNull(error),
|
||||
Validation.NormalizeMetadata(metadata),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public GraphOverlayJob(
|
||||
string id,
|
||||
string tenantId,
|
||||
string graphSnapshotId,
|
||||
string? buildJobId,
|
||||
GraphOverlayKind overlayKind,
|
||||
string overlayKey,
|
||||
ImmutableArray<string> subjects,
|
||||
GraphJobStatus status,
|
||||
GraphOverlayJobTrigger trigger,
|
||||
int attempts,
|
||||
string? correlationId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? completedAt,
|
||||
string? error,
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
GraphSnapshotId = Validation.EnsureId(graphSnapshotId, nameof(graphSnapshotId));
|
||||
BuildJobId = Validation.TrimToNull(buildJobId);
|
||||
OverlayKind = overlayKind;
|
||||
OverlayKey = Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey));
|
||||
Subjects = subjects.IsDefault ? ImmutableArray<string>.Empty : subjects;
|
||||
Status = status;
|
||||
Trigger = trigger;
|
||||
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
|
||||
CorrelationId = Validation.TrimToNull(correlationId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
CompletedAt = Validation.NormalizeTimestamp(completedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Metadata = materializedMetadata.Count > 0
|
||||
? materializedMetadata.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureGraphOverlayJob(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string GraphSnapshotId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? BuildJobId { get; init; }
|
||||
|
||||
public GraphOverlayKind OverlayKind { get; }
|
||||
|
||||
public string OverlayKey { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Subjects { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public GraphJobStatus Status { get; init; }
|
||||
|
||||
public GraphOverlayJobTrigger Trigger { get; }
|
||||
|
||||
public int Attempts { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result from resolving impacted images for a selector.
|
||||
/// </summary>
|
||||
public sealed record ImpactSet
|
||||
{
|
||||
public ImpactSet(
|
||||
Selector selector,
|
||||
IEnumerable<ImpactImage> images,
|
||||
bool usageOnly,
|
||||
DateTimeOffset generatedAt,
|
||||
int? total = null,
|
||||
string? snapshotId = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
selector,
|
||||
NormalizeImages(images),
|
||||
usageOnly,
|
||||
Validation.NormalizeTimestamp(generatedAt),
|
||||
total ?? images.Count(),
|
||||
Validation.TrimToNull(snapshotId),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ImpactSet(
|
||||
Selector selector,
|
||||
ImmutableArray<ImpactImage> images,
|
||||
bool usageOnly,
|
||||
DateTimeOffset generatedAt,
|
||||
int total,
|
||||
string? snapshotId,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Selector = selector ?? throw new ArgumentNullException(nameof(selector));
|
||||
Images = images.IsDefault ? ImmutableArray<ImpactImage>.Empty : images;
|
||||
UsageOnly = usageOnly;
|
||||
GeneratedAt = Validation.NormalizeTimestamp(generatedAt);
|
||||
Total = Validation.EnsureNonNegative(total, nameof(total));
|
||||
SnapshotId = Validation.TrimToNull(snapshotId);
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureImpactSet(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public Selector Selector { get; }
|
||||
|
||||
public ImmutableArray<ImpactImage> Images { get; } = ImmutableArray<ImpactImage>.Empty;
|
||||
|
||||
public bool UsageOnly { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; }
|
||||
|
||||
public int Total { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SnapshotId { get; }
|
||||
|
||||
private static ImmutableArray<ImpactImage> NormalizeImages(IEnumerable<ImpactImage> images)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(images);
|
||||
|
||||
return images
|
||||
.Where(static image => image is not null)
|
||||
.Select(static image => image!)
|
||||
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impacted image descriptor returned from the impact index.
|
||||
/// </summary>
|
||||
public sealed record ImpactImage
|
||||
{
|
||||
public ImpactImage(
|
||||
string imageDigest,
|
||||
string registry,
|
||||
string repository,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? tags = null,
|
||||
bool usedByEntrypoint = false,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null)
|
||||
: this(
|
||||
Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest)),
|
||||
Validation.EnsureSimpleIdentifier(registry, nameof(registry)),
|
||||
Validation.EnsureSimpleIdentifier(repository, nameof(repository)),
|
||||
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
|
||||
Validation.NormalizeTagPatterns(tags),
|
||||
usedByEntrypoint,
|
||||
Validation.NormalizeMetadata(labels))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ImpactImage(
|
||||
string imageDigest,
|
||||
string registry,
|
||||
string repository,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> tags,
|
||||
bool usedByEntrypoint,
|
||||
ImmutableSortedDictionary<string, string> labels)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
Registry = Validation.EnsureSimpleIdentifier(registry, nameof(registry));
|
||||
Repository = Validation.EnsureSimpleIdentifier(repository, nameof(repository));
|
||||
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
|
||||
Tags = tags.IsDefault ? ImmutableArray<string>.Empty : tags;
|
||||
UsedByEntrypoint = usedByEntrypoint;
|
||||
var materializedLabels = labels ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Labels = materializedLabels.Count > 0
|
||||
? materializedLabels.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public string Registry { get; }
|
||||
|
||||
public string Repository { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Tags { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public bool UsedByEntrypoint { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Labels { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result from resolving impacted images for a selector.
|
||||
/// </summary>
|
||||
public sealed record ImpactSet
|
||||
{
|
||||
public ImpactSet(
|
||||
Selector selector,
|
||||
IEnumerable<ImpactImage> images,
|
||||
bool usageOnly,
|
||||
DateTimeOffset generatedAt,
|
||||
int? total = null,
|
||||
string? snapshotId = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
selector,
|
||||
NormalizeImages(images),
|
||||
usageOnly,
|
||||
Validation.NormalizeTimestamp(generatedAt),
|
||||
total ?? images.Count(),
|
||||
Validation.TrimToNull(snapshotId),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ImpactSet(
|
||||
Selector selector,
|
||||
ImmutableArray<ImpactImage> images,
|
||||
bool usageOnly,
|
||||
DateTimeOffset generatedAt,
|
||||
int total,
|
||||
string? snapshotId,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Selector = selector ?? throw new ArgumentNullException(nameof(selector));
|
||||
Images = images.IsDefault ? ImmutableArray<ImpactImage>.Empty : images;
|
||||
UsageOnly = usageOnly;
|
||||
GeneratedAt = Validation.NormalizeTimestamp(generatedAt);
|
||||
Total = Validation.EnsureNonNegative(total, nameof(total));
|
||||
SnapshotId = Validation.TrimToNull(snapshotId);
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureImpactSet(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public Selector Selector { get; }
|
||||
|
||||
public ImmutableArray<ImpactImage> Images { get; } = ImmutableArray<ImpactImage>.Empty;
|
||||
|
||||
public bool UsageOnly { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; }
|
||||
|
||||
public int Total { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SnapshotId { get; }
|
||||
|
||||
private static ImmutableArray<ImpactImage> NormalizeImages(IEnumerable<ImpactImage> images)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(images);
|
||||
|
||||
return images
|
||||
.Where(static image => image is not null)
|
||||
.Select(static image => image!)
|
||||
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impacted image descriptor returned from the impact index.
|
||||
/// </summary>
|
||||
public sealed record ImpactImage
|
||||
{
|
||||
public ImpactImage(
|
||||
string imageDigest,
|
||||
string registry,
|
||||
string repository,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? tags = null,
|
||||
bool usedByEntrypoint = false,
|
||||
IEnumerable<KeyValuePair<string, string>>? labels = null)
|
||||
: this(
|
||||
Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest)),
|
||||
Validation.EnsureSimpleIdentifier(registry, nameof(registry)),
|
||||
Validation.EnsureSimpleIdentifier(repository, nameof(repository)),
|
||||
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
|
||||
Validation.NormalizeTagPatterns(tags),
|
||||
usedByEntrypoint,
|
||||
Validation.NormalizeMetadata(labels))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ImpactImage(
|
||||
string imageDigest,
|
||||
string registry,
|
||||
string repository,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> tags,
|
||||
bool usedByEntrypoint,
|
||||
ImmutableSortedDictionary<string, string> labels)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
Registry = Validation.EnsureSimpleIdentifier(registry, nameof(registry));
|
||||
Repository = Validation.EnsureSimpleIdentifier(repository, nameof(repository));
|
||||
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
|
||||
Tags = tags.IsDefault ? ImmutableArray<string>.Empty : tags;
|
||||
UsedByEntrypoint = usedByEntrypoint;
|
||||
var materializedLabels = labels ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
Labels = materializedLabels.Count > 0
|
||||
? materializedLabels.WithComparers(StringComparer.Ordinal)
|
||||
: ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public string Registry { get; }
|
||||
|
||||
public string Repository { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Tags { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public bool UsedByEntrypoint { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableSortedDictionary<string, string> Labels { get; } = ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// Temporary compatibility stub to allow transition away from MongoDB driver.
|
||||
namespace MongoDB.Driver
|
||||
{
|
||||
public interface IClientSessionHandle { }
|
||||
}
|
||||
@@ -1,185 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
public sealed record PolicyRunJob(
|
||||
string SchemaVersion,
|
||||
string Id,
|
||||
string TenantId,
|
||||
string PolicyId,
|
||||
int? PolicyVersion,
|
||||
PolicyRunMode Mode,
|
||||
PolicyRunPriority Priority,
|
||||
int PriorityRank,
|
||||
string? RunId,
|
||||
string? RequestedBy,
|
||||
string? CorrelationId,
|
||||
ImmutableSortedDictionary<string, string>? Metadata,
|
||||
PolicyRunInputs Inputs,
|
||||
DateTimeOffset? QueuedAt,
|
||||
PolicyRunJobStatus Status,
|
||||
int AttemptCount,
|
||||
DateTimeOffset? LastAttemptAt,
|
||||
string? LastError,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset AvailableAt,
|
||||
DateTimeOffset? SubmittedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? LeaseOwner,
|
||||
DateTimeOffset? LeaseExpiresAt,
|
||||
bool CancellationRequested,
|
||||
DateTimeOffset? CancellationRequestedAt,
|
||||
string? CancellationReason,
|
||||
DateTimeOffset? CancelledAt)
|
||||
{
|
||||
public string SchemaVersion { get; init; } = SchedulerSchemaVersions.EnsurePolicyRunJob(SchemaVersion);
|
||||
|
||||
public string Id { get; init; } = Validation.EnsureId(Id, nameof(Id));
|
||||
|
||||
public string TenantId { get; init; } = Validation.EnsureTenantId(TenantId, nameof(TenantId));
|
||||
|
||||
public string PolicyId { get; init; } = Validation.EnsureSimpleIdentifier(PolicyId, nameof(PolicyId));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? PolicyVersion { get; init; } = EnsurePolicyVersion(PolicyVersion);
|
||||
|
||||
public PolicyRunMode Mode { get; init; } = Mode;
|
||||
|
||||
public PolicyRunPriority Priority { get; init; } = Priority;
|
||||
|
||||
public int PriorityRank { get; init; } = PriorityRank >= 0 ? PriorityRank : GetPriorityRank(Priority);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RunId { get; init; } = NormalizeRunId(RunId);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RequestedBy { get; init; } = Validation.TrimToNull(RequestedBy);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; } = Validation.TrimToNull(CorrelationId);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableSortedDictionary<string, string>? Metadata { get; init; } = NormalizeMetadata(Metadata);
|
||||
|
||||
public PolicyRunInputs Inputs { get; init; } = Inputs ?? throw new ArgumentNullException(nameof(Inputs));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? QueuedAt { get; init; } = Validation.NormalizeTimestamp(QueuedAt);
|
||||
|
||||
public PolicyRunJobStatus Status { get; init; } = Status;
|
||||
|
||||
public int AttemptCount { get; init; } = Validation.EnsureNonNegative(AttemptCount, nameof(AttemptCount));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? LastAttemptAt { get; init; } = Validation.NormalizeTimestamp(LastAttemptAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LastError { get; init; } = Validation.TrimToNull(LastError);
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; } = NormalizeTimestamp(CreatedAt, nameof(CreatedAt));
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; } = NormalizeTimestamp(UpdatedAt, nameof(UpdatedAt));
|
||||
|
||||
public DateTimeOffset AvailableAt { get; init; } = NormalizeTimestamp(AvailableAt, nameof(AvailableAt));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? SubmittedAt { get; init; } = Validation.NormalizeTimestamp(SubmittedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; } = Validation.NormalizeTimestamp(CompletedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LeaseOwner { get; init; } = Validation.TrimToNull(LeaseOwner);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? LeaseExpiresAt { get; init; } = Validation.NormalizeTimestamp(LeaseExpiresAt);
|
||||
|
||||
public bool CancellationRequested { get; init; } = CancellationRequested;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CancellationRequestedAt { get; init; } = Validation.NormalizeTimestamp(CancellationRequestedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CancellationReason { get; init; } = Validation.TrimToNull(CancellationReason);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CancelledAt { get; init; } = Validation.NormalizeTimestamp(CancelledAt);
|
||||
|
||||
public PolicyRunRequest ToPolicyRunRequest(DateTimeOffset fallbackQueuedAt)
|
||||
{
|
||||
var queuedAt = QueuedAt ?? fallbackQueuedAt;
|
||||
return new PolicyRunRequest(
|
||||
TenantId,
|
||||
PolicyId,
|
||||
Mode,
|
||||
Inputs,
|
||||
Priority,
|
||||
RunId,
|
||||
PolicyVersion,
|
||||
RequestedBy,
|
||||
queuedAt,
|
||||
CorrelationId,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private static int? EnsurePolicyVersion(int? value)
|
||||
{
|
||||
if (value is not null && value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(PolicyVersion), value, "Policy version must be positive.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? NormalizeRunId(string? runId)
|
||||
{
|
||||
var trimmed = Validation.TrimToNull(runId);
|
||||
return trimmed is null ? null : Validation.EnsureId(trimmed, nameof(runId));
|
||||
}
|
||||
|
||||
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
var normalizedKey = Validation.TrimToNull(key);
|
||||
var normalizedValue = Validation.TrimToNull(value);
|
||||
if (normalizedKey is null || normalizedValue is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[normalizedKey.ToLowerInvariant()] = normalizedValue;
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? null : builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static int GetPriorityRank(PolicyRunPriority priority)
|
||||
=> priority switch
|
||||
{
|
||||
PolicyRunPriority.Emergency => 2,
|
||||
PolicyRunPriority.High => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static DateTimeOffset NormalizeTimestamp(DateTimeOffset value, string propertyName)
|
||||
{
|
||||
var normalized = Validation.NormalizeTimestamp(value);
|
||||
if (normalized == default)
|
||||
{
|
||||
throw new ArgumentException($"{propertyName} must be a valid timestamp.", propertyName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
public sealed record PolicyRunJob(
|
||||
string SchemaVersion,
|
||||
string Id,
|
||||
string TenantId,
|
||||
string PolicyId,
|
||||
int? PolicyVersion,
|
||||
PolicyRunMode Mode,
|
||||
PolicyRunPriority Priority,
|
||||
int PriorityRank,
|
||||
string? RunId,
|
||||
string? RequestedBy,
|
||||
string? CorrelationId,
|
||||
ImmutableSortedDictionary<string, string>? Metadata,
|
||||
PolicyRunInputs Inputs,
|
||||
DateTimeOffset? QueuedAt,
|
||||
PolicyRunJobStatus Status,
|
||||
int AttemptCount,
|
||||
DateTimeOffset? LastAttemptAt,
|
||||
string? LastError,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset AvailableAt,
|
||||
DateTimeOffset? SubmittedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? LeaseOwner,
|
||||
DateTimeOffset? LeaseExpiresAt,
|
||||
bool CancellationRequested,
|
||||
DateTimeOffset? CancellationRequestedAt,
|
||||
string? CancellationReason,
|
||||
DateTimeOffset? CancelledAt)
|
||||
{
|
||||
public string SchemaVersion { get; init; } = SchedulerSchemaVersions.EnsurePolicyRunJob(SchemaVersion);
|
||||
|
||||
public string Id { get; init; } = Validation.EnsureId(Id, nameof(Id));
|
||||
|
||||
public string TenantId { get; init; } = Validation.EnsureTenantId(TenantId, nameof(TenantId));
|
||||
|
||||
public string PolicyId { get; init; } = Validation.EnsureSimpleIdentifier(PolicyId, nameof(PolicyId));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? PolicyVersion { get; init; } = EnsurePolicyVersion(PolicyVersion);
|
||||
|
||||
public PolicyRunMode Mode { get; init; } = Mode;
|
||||
|
||||
public PolicyRunPriority Priority { get; init; } = Priority;
|
||||
|
||||
public int PriorityRank { get; init; } = PriorityRank >= 0 ? PriorityRank : GetPriorityRank(Priority);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RunId { get; init; } = NormalizeRunId(RunId);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RequestedBy { get; init; } = Validation.TrimToNull(RequestedBy);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CorrelationId { get; init; } = Validation.TrimToNull(CorrelationId);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableSortedDictionary<string, string>? Metadata { get; init; } = NormalizeMetadata(Metadata);
|
||||
|
||||
public PolicyRunInputs Inputs { get; init; } = Inputs ?? throw new ArgumentNullException(nameof(Inputs));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? QueuedAt { get; init; } = Validation.NormalizeTimestamp(QueuedAt);
|
||||
|
||||
public PolicyRunJobStatus Status { get; init; } = Status;
|
||||
|
||||
public int AttemptCount { get; init; } = Validation.EnsureNonNegative(AttemptCount, nameof(AttemptCount));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? LastAttemptAt { get; init; } = Validation.NormalizeTimestamp(LastAttemptAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LastError { get; init; } = Validation.TrimToNull(LastError);
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; } = NormalizeTimestamp(CreatedAt, nameof(CreatedAt));
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; } = NormalizeTimestamp(UpdatedAt, nameof(UpdatedAt));
|
||||
|
||||
public DateTimeOffset AvailableAt { get; init; } = NormalizeTimestamp(AvailableAt, nameof(AvailableAt));
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? SubmittedAt { get; init; } = Validation.NormalizeTimestamp(SubmittedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CompletedAt { get; init; } = Validation.NormalizeTimestamp(CompletedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LeaseOwner { get; init; } = Validation.TrimToNull(LeaseOwner);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? LeaseExpiresAt { get; init; } = Validation.NormalizeTimestamp(LeaseExpiresAt);
|
||||
|
||||
public bool CancellationRequested { get; init; } = CancellationRequested;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CancellationRequestedAt { get; init; } = Validation.NormalizeTimestamp(CancellationRequestedAt);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CancellationReason { get; init; } = Validation.TrimToNull(CancellationReason);
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? CancelledAt { get; init; } = Validation.NormalizeTimestamp(CancelledAt);
|
||||
|
||||
public PolicyRunRequest ToPolicyRunRequest(DateTimeOffset fallbackQueuedAt)
|
||||
{
|
||||
var queuedAt = QueuedAt ?? fallbackQueuedAt;
|
||||
return new PolicyRunRequest(
|
||||
TenantId,
|
||||
PolicyId,
|
||||
Mode,
|
||||
Inputs,
|
||||
Priority,
|
||||
RunId,
|
||||
PolicyVersion,
|
||||
RequestedBy,
|
||||
queuedAt,
|
||||
CorrelationId,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private static int? EnsurePolicyVersion(int? value)
|
||||
{
|
||||
if (value is not null && value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(PolicyVersion), value, "Policy version must be positive.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? NormalizeRunId(string? runId)
|
||||
{
|
||||
var trimmed = Validation.TrimToNull(runId);
|
||||
return trimmed is null ? null : Validation.EnsureId(trimmed, nameof(runId));
|
||||
}
|
||||
|
||||
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
var normalizedKey = Validation.TrimToNull(key);
|
||||
var normalizedValue = Validation.TrimToNull(value);
|
||||
if (normalizedKey is null || normalizedValue is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[normalizedKey.ToLowerInvariant()] = normalizedValue;
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? null : builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static int GetPriorityRank(PolicyRunPriority priority)
|
||||
=> priority switch
|
||||
{
|
||||
PolicyRunPriority.Emergency => 2,
|
||||
PolicyRunPriority.High => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static DateTimeOffset NormalizeTimestamp(DateTimeOffset value, string propertyName)
|
||||
{
|
||||
var normalized = Validation.NormalizeTimestamp(value);
|
||||
if (normalized == default)
|
||||
{
|
||||
throw new ArgumentException($"{propertyName} must be a valid timestamp.", propertyName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,33 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience helpers for <see cref="RunReason"/> mutations.
|
||||
/// </summary>
|
||||
public static class RunReasonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="reason"/> with impact window timestamps normalized to ISO-8601.
|
||||
/// </summary>
|
||||
public static RunReason WithImpactWindow(
|
||||
this RunReason reason,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to)
|
||||
{
|
||||
var normalizedFrom = Validation.NormalizeTimestamp(from);
|
||||
var normalizedTo = Validation.NormalizeTimestamp(to);
|
||||
|
||||
if (normalizedFrom.HasValue && normalizedTo.HasValue && normalizedFrom > normalizedTo)
|
||||
{
|
||||
throw new ArgumentException("Impact window start must be earlier than or equal to end.");
|
||||
}
|
||||
|
||||
return reason with
|
||||
{
|
||||
ImpactWindowFrom = normalizedFrom?.ToString("O", CultureInfo.InvariantCulture),
|
||||
ImpactWindowTo = normalizedTo?.ToString("O", CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience helpers for <see cref="RunReason"/> mutations.
|
||||
/// </summary>
|
||||
public static class RunReasonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="reason"/> with impact window timestamps normalized to ISO-8601.
|
||||
/// </summary>
|
||||
public static RunReason WithImpactWindow(
|
||||
this RunReason reason,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to)
|
||||
{
|
||||
var normalizedFrom = Validation.NormalizeTimestamp(from);
|
||||
var normalizedTo = Validation.NormalizeTimestamp(to);
|
||||
|
||||
if (normalizedFrom.HasValue && normalizedTo.HasValue && normalizedFrom > normalizedTo)
|
||||
{
|
||||
throw new ArgumentException("Impact window start must be earlier than or equal to end.");
|
||||
}
|
||||
|
||||
return reason with
|
||||
{
|
||||
ImpactWindowFrom = normalizedFrom?.ToString("O", CultureInfo.InvariantCulture),
|
||||
ImpactWindowTo = normalizedTo?.ToString("O", CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates allowed <see cref="RunState"/> transitions and invariants.
|
||||
/// </summary>
|
||||
public static class RunStateMachine
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<RunState, RunState[]> Adjacency = new Dictionary<RunState, RunState[]>
|
||||
{
|
||||
[RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled },
|
||||
[RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled },
|
||||
[RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled },
|
||||
[RunState.Completed] = new[] { RunState.Completed },
|
||||
[RunState.Error] = new[] { RunState.Error },
|
||||
[RunState.Cancelled] = new[] { RunState.Cancelled },
|
||||
};
|
||||
|
||||
public static bool CanTransition(RunState from, RunState to)
|
||||
{
|
||||
if (!Adjacency.TryGetValue(from, out var allowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowed.Contains(to);
|
||||
}
|
||||
|
||||
public static bool IsTerminal(RunState state)
|
||||
=> state is RunState.Completed or RunState.Error or RunState.Cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent.
|
||||
/// </summary>
|
||||
public static Run EnsureTransition(
|
||||
Run run,
|
||||
RunState next,
|
||||
DateTimeOffset timestamp,
|
||||
Action<RunStatsBuilder>? mutateStats = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = run.State;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var statsBuilder = new RunStatsBuilder(run.Stats);
|
||||
mutateStats?.Invoke(statsBuilder);
|
||||
var newStats = statsBuilder.Build();
|
||||
|
||||
var startedAt = run.StartedAt;
|
||||
var finishedAt = run.FinishedAt;
|
||||
|
||||
if (current != RunState.Running && next == RunState.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
finishedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (startedAt is { } start && start < run.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run started time cannot be earlier than created time.");
|
||||
}
|
||||
|
||||
if (finishedAt is { } finish)
|
||||
{
|
||||
if (startedAt is { } startTime && finish < startTime)
|
||||
{
|
||||
throw new InvalidOperationException("Run finished time cannot be earlier than start time.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(next))
|
||||
{
|
||||
throw new InvalidOperationException("Finished time present but next state is not terminal.");
|
||||
}
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == RunState.Error)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Error requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Error state.");
|
||||
}
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = next,
|
||||
Stats = newStats,
|
||||
StartedAt = startedAt,
|
||||
FinishedAt = finishedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static void Validate(Run run)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
|
||||
if (run.StartedAt is { } started && started < run.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (run.FinishedAt is { } finished)
|
||||
{
|
||||
if (run.StartedAt is { } startedAt && finished < startedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(run.State))
|
||||
{
|
||||
throw new InvalidOperationException("Run.FinishedAt set while state is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(run.State))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal run states must include FinishedAt.");
|
||||
}
|
||||
|
||||
if (run.State == RunState.Error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Error))
|
||||
{
|
||||
throw new InvalidOperationException("Run.Error must be populated when state is Error.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(run.Error))
|
||||
{
|
||||
throw new InvalidOperationException("Run.Error must be null for non-error states.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates allowed <see cref="RunState"/> transitions and invariants.
|
||||
/// </summary>
|
||||
public static class RunStateMachine
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<RunState, RunState[]> Adjacency = new Dictionary<RunState, RunState[]>
|
||||
{
|
||||
[RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled },
|
||||
[RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled },
|
||||
[RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled },
|
||||
[RunState.Completed] = new[] { RunState.Completed },
|
||||
[RunState.Error] = new[] { RunState.Error },
|
||||
[RunState.Cancelled] = new[] { RunState.Cancelled },
|
||||
};
|
||||
|
||||
public static bool CanTransition(RunState from, RunState to)
|
||||
{
|
||||
if (!Adjacency.TryGetValue(from, out var allowed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowed.Contains(to);
|
||||
}
|
||||
|
||||
public static bool IsTerminal(RunState state)
|
||||
=> state is RunState.Completed or RunState.Error or RunState.Cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent.
|
||||
/// </summary>
|
||||
public static Run EnsureTransition(
|
||||
Run run,
|
||||
RunState next,
|
||||
DateTimeOffset timestamp,
|
||||
Action<RunStatsBuilder>? mutateStats = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
|
||||
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
||||
var current = run.State;
|
||||
|
||||
if (!CanTransition(current, next))
|
||||
{
|
||||
throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed.");
|
||||
}
|
||||
|
||||
var statsBuilder = new RunStatsBuilder(run.Stats);
|
||||
mutateStats?.Invoke(statsBuilder);
|
||||
var newStats = statsBuilder.Build();
|
||||
|
||||
var startedAt = run.StartedAt;
|
||||
var finishedAt = run.FinishedAt;
|
||||
|
||||
if (current != RunState.Running && next == RunState.Running && startedAt is null)
|
||||
{
|
||||
startedAt = normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (IsTerminal(next))
|
||||
{
|
||||
finishedAt ??= normalizedTimestamp;
|
||||
}
|
||||
|
||||
if (startedAt is { } start && start < run.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run started time cannot be earlier than created time.");
|
||||
}
|
||||
|
||||
if (finishedAt is { } finish)
|
||||
{
|
||||
if (startedAt is { } startTime && finish < startTime)
|
||||
{
|
||||
throw new InvalidOperationException("Run finished time cannot be earlier than start time.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(next))
|
||||
{
|
||||
throw new InvalidOperationException("Finished time present but next state is not terminal.");
|
||||
}
|
||||
}
|
||||
|
||||
string? nextError = null;
|
||||
if (next == RunState.Error)
|
||||
{
|
||||
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim();
|
||||
if (string.IsNullOrWhiteSpace(effectiveError))
|
||||
{
|
||||
throw new InvalidOperationException("Transitioning to Error requires a non-empty error message.");
|
||||
}
|
||||
|
||||
nextError = effectiveError;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
throw new InvalidOperationException("Error message can only be provided when transitioning to Error state.");
|
||||
}
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = next,
|
||||
Stats = newStats,
|
||||
StartedAt = startedAt,
|
||||
FinishedAt = finishedAt,
|
||||
Error = nextError,
|
||||
};
|
||||
|
||||
Validate(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public static void Validate(Run run)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
|
||||
if (run.StartedAt is { } started && started < run.CreatedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt.");
|
||||
}
|
||||
|
||||
if (run.FinishedAt is { } finished)
|
||||
{
|
||||
if (run.StartedAt is { } startedAt && finished < startedAt)
|
||||
{
|
||||
throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(run.State))
|
||||
{
|
||||
throw new InvalidOperationException("Run.FinishedAt set while state is not terminal.");
|
||||
}
|
||||
}
|
||||
else if (IsTerminal(run.State))
|
||||
{
|
||||
throw new InvalidOperationException("Terminal run states must include FinishedAt.");
|
||||
}
|
||||
|
||||
if (run.State == RunState.Error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Error))
|
||||
{
|
||||
throw new InvalidOperationException("Run.Error must be populated when state is Error.");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(run.Error))
|
||||
{
|
||||
throw new InvalidOperationException("Run.Error must be null for non-error states.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Helper that enforces monotonic <see cref="RunStats"/> updates.
|
||||
/// </summary>
|
||||
public sealed class RunStatsBuilder
|
||||
{
|
||||
private int _candidates;
|
||||
private int _deduped;
|
||||
private int _queued;
|
||||
private int _completed;
|
||||
private int _deltas;
|
||||
private int _newCriticals;
|
||||
private int _newHigh;
|
||||
private int _newMedium;
|
||||
private int _newLow;
|
||||
|
||||
public RunStatsBuilder(RunStats? baseline = null)
|
||||
{
|
||||
baseline ??= RunStats.Empty;
|
||||
_candidates = baseline.Candidates;
|
||||
_deduped = baseline.Deduped;
|
||||
_queued = baseline.Queued;
|
||||
_completed = baseline.Completed;
|
||||
_deltas = baseline.Deltas;
|
||||
_newCriticals = baseline.NewCriticals;
|
||||
_newHigh = baseline.NewHigh;
|
||||
_newMedium = baseline.NewMedium;
|
||||
_newLow = baseline.NewLow;
|
||||
}
|
||||
|
||||
public void SetCandidates(int value) => _candidates = EnsureMonotonic(value, _candidates, nameof(RunStats.Candidates));
|
||||
|
||||
public void IncrementCandidates(int value = 1) => SetCandidates(_candidates + value);
|
||||
|
||||
public void SetDeduped(int value) => _deduped = EnsureMonotonic(value, _deduped, nameof(RunStats.Deduped));
|
||||
|
||||
public void IncrementDeduped(int value = 1) => SetDeduped(_deduped + value);
|
||||
|
||||
public void SetQueued(int value) => _queued = EnsureMonotonic(value, _queued, nameof(RunStats.Queued));
|
||||
|
||||
public void IncrementQueued(int value = 1) => SetQueued(_queued + value);
|
||||
|
||||
public void SetCompleted(int value) => _completed = EnsureMonotonic(value, _completed, nameof(RunStats.Completed));
|
||||
|
||||
public void IncrementCompleted(int value = 1) => SetCompleted(_completed + value);
|
||||
|
||||
public void SetDeltas(int value) => _deltas = EnsureMonotonic(value, _deltas, nameof(RunStats.Deltas));
|
||||
|
||||
public void IncrementDeltas(int value = 1) => SetDeltas(_deltas + value);
|
||||
|
||||
public void SetNewCriticals(int value) => _newCriticals = EnsureMonotonic(value, _newCriticals, nameof(RunStats.NewCriticals));
|
||||
|
||||
public void IncrementNewCriticals(int value = 1) => SetNewCriticals(_newCriticals + value);
|
||||
|
||||
public void SetNewHigh(int value) => _newHigh = EnsureMonotonic(value, _newHigh, nameof(RunStats.NewHigh));
|
||||
|
||||
public void IncrementNewHigh(int value = 1) => SetNewHigh(_newHigh + value);
|
||||
|
||||
public void SetNewMedium(int value) => _newMedium = EnsureMonotonic(value, _newMedium, nameof(RunStats.NewMedium));
|
||||
|
||||
public void IncrementNewMedium(int value = 1) => SetNewMedium(_newMedium + value);
|
||||
|
||||
public void SetNewLow(int value) => _newLow = EnsureMonotonic(value, _newLow, nameof(RunStats.NewLow));
|
||||
|
||||
public void IncrementNewLow(int value = 1) => SetNewLow(_newLow + value);
|
||||
|
||||
public RunStats Build()
|
||||
=> new(
|
||||
candidates: _candidates,
|
||||
deduped: _deduped,
|
||||
queued: _queued,
|
||||
completed: _completed,
|
||||
deltas: _deltas,
|
||||
newCriticals: _newCriticals,
|
||||
newHigh: _newHigh,
|
||||
newMedium: _newMedium,
|
||||
newLow: _newLow);
|
||||
|
||||
private static int EnsureMonotonic(int value, int current, string fieldName)
|
||||
{
|
||||
Validation.EnsureNonNegative(value, fieldName);
|
||||
if (value < current)
|
||||
{
|
||||
throw new InvalidOperationException($"RunStats.{fieldName} cannot decrease (current: {current}, attempted: {value}).");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Helper that enforces monotonic <see cref="RunStats"/> updates.
|
||||
/// </summary>
|
||||
public sealed class RunStatsBuilder
|
||||
{
|
||||
private int _candidates;
|
||||
private int _deduped;
|
||||
private int _queued;
|
||||
private int _completed;
|
||||
private int _deltas;
|
||||
private int _newCriticals;
|
||||
private int _newHigh;
|
||||
private int _newMedium;
|
||||
private int _newLow;
|
||||
|
||||
public RunStatsBuilder(RunStats? baseline = null)
|
||||
{
|
||||
baseline ??= RunStats.Empty;
|
||||
_candidates = baseline.Candidates;
|
||||
_deduped = baseline.Deduped;
|
||||
_queued = baseline.Queued;
|
||||
_completed = baseline.Completed;
|
||||
_deltas = baseline.Deltas;
|
||||
_newCriticals = baseline.NewCriticals;
|
||||
_newHigh = baseline.NewHigh;
|
||||
_newMedium = baseline.NewMedium;
|
||||
_newLow = baseline.NewLow;
|
||||
}
|
||||
|
||||
public void SetCandidates(int value) => _candidates = EnsureMonotonic(value, _candidates, nameof(RunStats.Candidates));
|
||||
|
||||
public void IncrementCandidates(int value = 1) => SetCandidates(_candidates + value);
|
||||
|
||||
public void SetDeduped(int value) => _deduped = EnsureMonotonic(value, _deduped, nameof(RunStats.Deduped));
|
||||
|
||||
public void IncrementDeduped(int value = 1) => SetDeduped(_deduped + value);
|
||||
|
||||
public void SetQueued(int value) => _queued = EnsureMonotonic(value, _queued, nameof(RunStats.Queued));
|
||||
|
||||
public void IncrementQueued(int value = 1) => SetQueued(_queued + value);
|
||||
|
||||
public void SetCompleted(int value) => _completed = EnsureMonotonic(value, _completed, nameof(RunStats.Completed));
|
||||
|
||||
public void IncrementCompleted(int value = 1) => SetCompleted(_completed + value);
|
||||
|
||||
public void SetDeltas(int value) => _deltas = EnsureMonotonic(value, _deltas, nameof(RunStats.Deltas));
|
||||
|
||||
public void IncrementDeltas(int value = 1) => SetDeltas(_deltas + value);
|
||||
|
||||
public void SetNewCriticals(int value) => _newCriticals = EnsureMonotonic(value, _newCriticals, nameof(RunStats.NewCriticals));
|
||||
|
||||
public void IncrementNewCriticals(int value = 1) => SetNewCriticals(_newCriticals + value);
|
||||
|
||||
public void SetNewHigh(int value) => _newHigh = EnsureMonotonic(value, _newHigh, nameof(RunStats.NewHigh));
|
||||
|
||||
public void IncrementNewHigh(int value = 1) => SetNewHigh(_newHigh + value);
|
||||
|
||||
public void SetNewMedium(int value) => _newMedium = EnsureMonotonic(value, _newMedium, nameof(RunStats.NewMedium));
|
||||
|
||||
public void IncrementNewMedium(int value = 1) => SetNewMedium(_newMedium + value);
|
||||
|
||||
public void SetNewLow(int value) => _newLow = EnsureMonotonic(value, _newLow, nameof(RunStats.NewLow));
|
||||
|
||||
public void IncrementNewLow(int value = 1) => SetNewLow(_newLow + value);
|
||||
|
||||
public RunStats Build()
|
||||
=> new(
|
||||
candidates: _candidates,
|
||||
deduped: _deduped,
|
||||
queued: _queued,
|
||||
completed: _completed,
|
||||
deltas: _deltas,
|
||||
newCriticals: _newCriticals,
|
||||
newHigh: _newHigh,
|
||||
newMedium: _newMedium,
|
||||
newLow: _newLow);
|
||||
|
||||
private static int EnsureMonotonic(int value, int current, string fieldName)
|
||||
{
|
||||
Validation.EnsureNonNegative(value, fieldName);
|
||||
if (value < current)
|
||||
{
|
||||
throw new InvalidOperationException($"RunStats.{fieldName} cannot decrease (current: {current}, attempted: {value}).");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +1,227 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler configuration entity persisted in Mongo.
|
||||
/// </summary>
|
||||
public sealed record Schedule
|
||||
{
|
||||
public Schedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
string name,
|
||||
bool enabled,
|
||||
string cronExpression,
|
||||
string timezone,
|
||||
ScheduleMode mode,
|
||||
Selector selection,
|
||||
ScheduleOnlyIf? onlyIf,
|
||||
ScheduleNotify? notify,
|
||||
ScheduleLimits? limits,
|
||||
DateTimeOffset createdAt,
|
||||
string createdBy,
|
||||
DateTimeOffset updatedAt,
|
||||
string updatedBy,
|
||||
ImmutableArray<string>? subscribers = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
enabled,
|
||||
cronExpression,
|
||||
timezone,
|
||||
mode,
|
||||
selection,
|
||||
onlyIf ?? ScheduleOnlyIf.Default,
|
||||
notify ?? ScheduleNotify.Default,
|
||||
limits ?? ScheduleLimits.Default,
|
||||
subscribers ?? ImmutableArray<string>.Empty,
|
||||
createdAt,
|
||||
createdBy,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Schedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
string name,
|
||||
bool enabled,
|
||||
string cronExpression,
|
||||
string timezone,
|
||||
ScheduleMode mode,
|
||||
Selector selection,
|
||||
ScheduleOnlyIf onlyIf,
|
||||
ScheduleNotify notify,
|
||||
ScheduleLimits limits,
|
||||
ImmutableArray<string> subscribers,
|
||||
DateTimeOffset createdAt,
|
||||
string createdBy,
|
||||
DateTimeOffset updatedAt,
|
||||
string updatedBy,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Name = Validation.EnsureName(name, nameof(name));
|
||||
Enabled = enabled;
|
||||
CronExpression = Validation.EnsureCronExpression(cronExpression, nameof(cronExpression));
|
||||
Timezone = Validation.EnsureTimezone(timezone, nameof(timezone));
|
||||
Mode = mode;
|
||||
Selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
OnlyIf = onlyIf ?? ScheduleOnlyIf.Default;
|
||||
Notify = notify ?? ScheduleNotify.Default;
|
||||
Limits = limits ?? ScheduleLimits.Default;
|
||||
Subscribers = (subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers)
|
||||
.Select(static value => Validation.EnsureSimpleIdentifier(value, nameof(subscribers)))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
CreatedBy = Validation.EnsureSimpleIdentifier(createdBy, nameof(createdBy));
|
||||
UpdatedAt = Validation.NormalizeTimestamp(updatedAt);
|
||||
UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy));
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion);
|
||||
|
||||
if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Selection tenant must match schedule tenant.", nameof(selection));
|
||||
}
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string CronExpression { get; }
|
||||
|
||||
public string Timezone { get; }
|
||||
|
||||
public ScheduleMode Mode { get; }
|
||||
|
||||
public Selector Selection { get; }
|
||||
|
||||
public ScheduleOnlyIf OnlyIf { get; }
|
||||
|
||||
public ScheduleNotify Notify { get; }
|
||||
|
||||
public ScheduleLimits Limits { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Subscribers { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public string UpdatedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditions that must hold before a schedule enqueues work.
|
||||
/// </summary>
|
||||
public sealed record ScheduleOnlyIf
|
||||
{
|
||||
public static ScheduleOnlyIf Default { get; } = new();
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleOnlyIf(int? lastReportOlderThanDays = null, string? policyRevision = null)
|
||||
{
|
||||
LastReportOlderThanDays = Validation.EnsurePositiveOrNull(lastReportOlderThanDays, nameof(lastReportOlderThanDays));
|
||||
PolicyRevision = Validation.TrimToNull(policyRevision);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? LastReportOlderThanDays { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? PolicyRevision { get; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification preferences for schedule outcomes.
|
||||
/// </summary>
|
||||
public sealed record ScheduleNotify
|
||||
{
|
||||
public static ScheduleNotify Default { get; } = new(onNewFindings: true, null, includeKev: true);
|
||||
|
||||
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev)
|
||||
{
|
||||
OnNewFindings = onNewFindings;
|
||||
if (minSeverity is SeverityRank.Unknown or SeverityRank.None)
|
||||
{
|
||||
MinSeverity = minSeverity == SeverityRank.Unknown ? SeverityRank.Unknown : SeverityRank.Low;
|
||||
}
|
||||
else
|
||||
{
|
||||
MinSeverity = minSeverity;
|
||||
}
|
||||
|
||||
IncludeKev = includeKev;
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev, bool includeQuietFindings = false)
|
||||
: this(onNewFindings, minSeverity, includeKev)
|
||||
{
|
||||
IncludeQuietFindings = includeQuietFindings;
|
||||
}
|
||||
|
||||
public bool OnNewFindings { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SeverityRank? MinSeverity { get; }
|
||||
|
||||
public bool IncludeKev { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool IncludeQuietFindings { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution limits that bound scheduler throughput.
|
||||
/// </summary>
|
||||
public sealed record ScheduleLimits
|
||||
{
|
||||
public static ScheduleLimits Default { get; } = new();
|
||||
|
||||
public ScheduleLimits(int? maxJobs = null, int? ratePerSecond = null, int? parallelism = null)
|
||||
{
|
||||
MaxJobs = Validation.EnsurePositiveOrNull(maxJobs, nameof(maxJobs));
|
||||
RatePerSecond = Validation.EnsurePositiveOrNull(ratePerSecond, nameof(ratePerSecond));
|
||||
Parallelism = Validation.EnsurePositiveOrNull(parallelism, nameof(parallelism));
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleLimits(int? maxJobs, int? ratePerSecond, int? parallelism, int? burst = null)
|
||||
: this(maxJobs, ratePerSecond, parallelism)
|
||||
{
|
||||
Burst = Validation.EnsurePositiveOrNull(burst, nameof(burst));
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? MaxJobs { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? RatePerSecond { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Parallelism { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Burst { get; }
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler configuration entity persisted in Mongo.
|
||||
/// </summary>
|
||||
public sealed record Schedule
|
||||
{
|
||||
public Schedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
string name,
|
||||
bool enabled,
|
||||
string cronExpression,
|
||||
string timezone,
|
||||
ScheduleMode mode,
|
||||
Selector selection,
|
||||
ScheduleOnlyIf? onlyIf,
|
||||
ScheduleNotify? notify,
|
||||
ScheduleLimits? limits,
|
||||
DateTimeOffset createdAt,
|
||||
string createdBy,
|
||||
DateTimeOffset updatedAt,
|
||||
string updatedBy,
|
||||
ImmutableArray<string>? subscribers = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
enabled,
|
||||
cronExpression,
|
||||
timezone,
|
||||
mode,
|
||||
selection,
|
||||
onlyIf ?? ScheduleOnlyIf.Default,
|
||||
notify ?? ScheduleNotify.Default,
|
||||
limits ?? ScheduleLimits.Default,
|
||||
subscribers ?? ImmutableArray<string>.Empty,
|
||||
createdAt,
|
||||
createdBy,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Schedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
string name,
|
||||
bool enabled,
|
||||
string cronExpression,
|
||||
string timezone,
|
||||
ScheduleMode mode,
|
||||
Selector selection,
|
||||
ScheduleOnlyIf onlyIf,
|
||||
ScheduleNotify notify,
|
||||
ScheduleLimits limits,
|
||||
ImmutableArray<string> subscribers,
|
||||
DateTimeOffset createdAt,
|
||||
string createdBy,
|
||||
DateTimeOffset updatedAt,
|
||||
string updatedBy,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Name = Validation.EnsureName(name, nameof(name));
|
||||
Enabled = enabled;
|
||||
CronExpression = Validation.EnsureCronExpression(cronExpression, nameof(cronExpression));
|
||||
Timezone = Validation.EnsureTimezone(timezone, nameof(timezone));
|
||||
Mode = mode;
|
||||
Selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
OnlyIf = onlyIf ?? ScheduleOnlyIf.Default;
|
||||
Notify = notify ?? ScheduleNotify.Default;
|
||||
Limits = limits ?? ScheduleLimits.Default;
|
||||
Subscribers = (subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers)
|
||||
.Select(static value => Validation.EnsureSimpleIdentifier(value, nameof(subscribers)))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
CreatedBy = Validation.EnsureSimpleIdentifier(createdBy, nameof(createdBy));
|
||||
UpdatedAt = Validation.NormalizeTimestamp(updatedAt);
|
||||
UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy));
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion);
|
||||
|
||||
if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Selection tenant must match schedule tenant.", nameof(selection));
|
||||
}
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string CronExpression { get; }
|
||||
|
||||
public string Timezone { get; }
|
||||
|
||||
public ScheduleMode Mode { get; }
|
||||
|
||||
public Selector Selection { get; }
|
||||
|
||||
public ScheduleOnlyIf OnlyIf { get; }
|
||||
|
||||
public ScheduleNotify Notify { get; }
|
||||
|
||||
public ScheduleLimits Limits { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Subscribers { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public string UpdatedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditions that must hold before a schedule enqueues work.
|
||||
/// </summary>
|
||||
public sealed record ScheduleOnlyIf
|
||||
{
|
||||
public static ScheduleOnlyIf Default { get; } = new();
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleOnlyIf(int? lastReportOlderThanDays = null, string? policyRevision = null)
|
||||
{
|
||||
LastReportOlderThanDays = Validation.EnsurePositiveOrNull(lastReportOlderThanDays, nameof(lastReportOlderThanDays));
|
||||
PolicyRevision = Validation.TrimToNull(policyRevision);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? LastReportOlderThanDays { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? PolicyRevision { get; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification preferences for schedule outcomes.
|
||||
/// </summary>
|
||||
public sealed record ScheduleNotify
|
||||
{
|
||||
public static ScheduleNotify Default { get; } = new(onNewFindings: true, null, includeKev: true);
|
||||
|
||||
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev)
|
||||
{
|
||||
OnNewFindings = onNewFindings;
|
||||
if (minSeverity is SeverityRank.Unknown or SeverityRank.None)
|
||||
{
|
||||
MinSeverity = minSeverity == SeverityRank.Unknown ? SeverityRank.Unknown : SeverityRank.Low;
|
||||
}
|
||||
else
|
||||
{
|
||||
MinSeverity = minSeverity;
|
||||
}
|
||||
|
||||
IncludeKev = includeKev;
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev, bool includeQuietFindings = false)
|
||||
: this(onNewFindings, minSeverity, includeKev)
|
||||
{
|
||||
IncludeQuietFindings = includeQuietFindings;
|
||||
}
|
||||
|
||||
public bool OnNewFindings { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SeverityRank? MinSeverity { get; }
|
||||
|
||||
public bool IncludeKev { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool IncludeQuietFindings { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution limits that bound scheduler throughput.
|
||||
/// </summary>
|
||||
public sealed record ScheduleLimits
|
||||
{
|
||||
public static ScheduleLimits Default { get; } = new();
|
||||
|
||||
public ScheduleLimits(int? maxJobs = null, int? ratePerSecond = null, int? parallelism = null)
|
||||
{
|
||||
MaxJobs = Validation.EnsurePositiveOrNull(maxJobs, nameof(maxJobs));
|
||||
RatePerSecond = Validation.EnsurePositiveOrNull(ratePerSecond, nameof(ratePerSecond));
|
||||
Parallelism = Validation.EnsurePositiveOrNull(parallelism, nameof(parallelism));
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public ScheduleLimits(int? maxJobs, int? ratePerSecond, int? parallelism, int? burst = null)
|
||||
: this(maxJobs, ratePerSecond, parallelism)
|
||||
{
|
||||
Burst = Validation.EnsurePositiveOrNull(burst, nameof(burst));
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? MaxJobs { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? RatePerSecond { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Parallelism { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Burst { get; }
|
||||
}
|
||||
|
||||
@@ -5,58 +5,58 @@ using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
|
||||
/// </summary>
|
||||
public static class SchedulerSchemaMigration
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"name",
|
||||
"enabled",
|
||||
"cronExpression",
|
||||
"timezone",
|
||||
"mode",
|
||||
"selection",
|
||||
"onlyIf",
|
||||
"notify",
|
||||
"limits",
|
||||
"subscribers",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"updatedAt",
|
||||
"updatedBy");
|
||||
|
||||
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"scheduleId",
|
||||
"trigger",
|
||||
"state",
|
||||
"stats",
|
||||
"reason",
|
||||
"createdAt",
|
||||
"startedAt",
|
||||
"finishedAt",
|
||||
"error",
|
||||
"deltas");
|
||||
|
||||
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"selector",
|
||||
"images",
|
||||
"usageOnly",
|
||||
"generatedAt",
|
||||
"total",
|
||||
"snapshotId");
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
|
||||
/// </summary>
|
||||
public static class SchedulerSchemaMigration
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"name",
|
||||
"enabled",
|
||||
"cronExpression",
|
||||
"timezone",
|
||||
"mode",
|
||||
"selection",
|
||||
"onlyIf",
|
||||
"notify",
|
||||
"limits",
|
||||
"subscribers",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"updatedAt",
|
||||
"updatedBy");
|
||||
|
||||
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"scheduleId",
|
||||
"trigger",
|
||||
"state",
|
||||
"stats",
|
||||
"reason",
|
||||
"createdAt",
|
||||
"startedAt",
|
||||
"finishedAt",
|
||||
"error",
|
||||
"deltas");
|
||||
|
||||
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
|
||||
StringComparer.Ordinal,
|
||||
"schemaVersion",
|
||||
"selector",
|
||||
"images",
|
||||
"usageOnly",
|
||||
"generatedAt",
|
||||
"total",
|
||||
"snapshotId");
|
||||
|
||||
public static SchedulerSchemaMigrationResult<Schedule> UpgradeSchedule(JsonNode document, bool strict = false)
|
||||
=> Upgrade(
|
||||
document,
|
||||
@@ -124,43 +124,43 @@ public static class SchedulerSchemaMigration
|
||||
|
||||
var value = deserialize(canonicalJson);
|
||||
return new SchedulerSchemaMigrationResult<T>(
|
||||
value,
|
||||
fromVersion,
|
||||
latestVersion,
|
||||
warnings.ToImmutable());
|
||||
}
|
||||
|
||||
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException("Document must be a JSON object.", nameof(node));
|
||||
}
|
||||
|
||||
if (obj.DeepClone() is not JsonObject clone)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clone scheduler document.");
|
||||
}
|
||||
|
||||
string schemaVersion;
|
||||
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
|
||||
value is JsonValue jsonValue &&
|
||||
jsonValue.TryGetValue(out string? rawVersion))
|
||||
{
|
||||
schemaVersion = ensureVersion(rawVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaVersion = ensureVersion(null);
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
}
|
||||
|
||||
// Ensure schemaVersion is normalized in the clone.
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
|
||||
return (clone, schemaVersion);
|
||||
}
|
||||
|
||||
value,
|
||||
fromVersion,
|
||||
latestVersion,
|
||||
warnings.ToImmutable());
|
||||
}
|
||||
|
||||
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException("Document must be a JSON object.", nameof(node));
|
||||
}
|
||||
|
||||
if (obj.DeepClone() is not JsonObject clone)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clone scheduler document.");
|
||||
}
|
||||
|
||||
string schemaVersion;
|
||||
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
|
||||
value is JsonValue jsonValue &&
|
||||
jsonValue.TryGetValue(out string? rawVersion))
|
||||
{
|
||||
schemaVersion = ensureVersion(rawVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaVersion = ensureVersion(null);
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
}
|
||||
|
||||
// Ensure schemaVersion is normalized in the clone.
|
||||
clone["schemaVersion"] = schemaVersion;
|
||||
|
||||
return (clone, schemaVersion);
|
||||
}
|
||||
|
||||
private static void RemoveUnknownMembers(
|
||||
JsonObject json,
|
||||
ImmutableHashSet<string> knownProperties,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result from upgrading a scheduler document to the latest schema version.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Target DTO type.</typeparam>
|
||||
public sealed record SchedulerSchemaMigrationResult<T>(
|
||||
T Value,
|
||||
string FromVersion,
|
||||
string ToVersion,
|
||||
ImmutableArray<string> Warnings);
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result from upgrading a scheduler document to the latest schema version.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Target DTO type.</typeparam>
|
||||
public sealed record SchedulerSchemaMigrationResult<T>(
|
||||
T Value,
|
||||
string FromVersion,
|
||||
string ToVersion,
|
||||
ImmutableArray<string> Warnings);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical schema version identifiers for scheduler documents.
|
||||
/// </summary>
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical schema version identifiers for scheduler documents.
|
||||
/// </summary>
|
||||
public static class SchedulerSchemaVersions
|
||||
{
|
||||
public const string Schedule = "scheduler.schedule@1";
|
||||
@@ -21,7 +21,7 @@ public static class SchedulerSchemaVersions
|
||||
|
||||
public static string EnsureSchedule(string? value)
|
||||
=> Normalize(value, Schedule);
|
||||
|
||||
|
||||
public static string EnsureRun(string? value)
|
||||
=> Normalize(value, Run);
|
||||
|
||||
|
||||
@@ -1,134 +1,134 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Selector filters used to resolve impacted assets.
|
||||
/// </summary>
|
||||
public sealed record Selector
|
||||
{
|
||||
public Selector(
|
||||
SelectorScope scope,
|
||||
string? tenantId = null,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? repositories = null,
|
||||
IEnumerable<string>? digests = null,
|
||||
IEnumerable<string>? includeTags = null,
|
||||
IEnumerable<LabelSelector>? labels = null,
|
||||
bool resolvesTags = false)
|
||||
: this(
|
||||
scope,
|
||||
tenantId,
|
||||
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
|
||||
Validation.NormalizeStringSet(repositories, nameof(repositories)),
|
||||
Validation.NormalizeDigests(digests, nameof(digests)),
|
||||
Validation.NormalizeTagPatterns(includeTags),
|
||||
NormalizeLabels(labels),
|
||||
resolvesTags)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Selector(
|
||||
SelectorScope scope,
|
||||
string? tenantId,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> digests,
|
||||
ImmutableArray<string> includeTags,
|
||||
ImmutableArray<LabelSelector> labels,
|
||||
bool resolvesTags)
|
||||
{
|
||||
Scope = scope;
|
||||
TenantId = tenantId is null ? null : Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
|
||||
Repositories = repositories.IsDefault ? ImmutableArray<string>.Empty : repositories;
|
||||
Digests = digests.IsDefault ? ImmutableArray<string>.Empty : digests;
|
||||
IncludeTags = includeTags.IsDefault ? ImmutableArray<string>.Empty : includeTags;
|
||||
Labels = labels.IsDefault ? ImmutableArray<LabelSelector>.Empty : labels;
|
||||
ResolvesTags = resolvesTags;
|
||||
|
||||
if (Scope is SelectorScope.ByDigest && Digests.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one digest is required when scope is by-digest.", nameof(digests));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByNamespace && Namespaces.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Namespaces are required when scope is by-namespace.", nameof(namespaces));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByRepository && Repositories.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Repositories are required when scope is by-repo.", nameof(repositories));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByLabels && Labels.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Labels are required when scope is by-labels.", nameof(labels));
|
||||
}
|
||||
}
|
||||
|
||||
public SelectorScope Scope { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Repositories { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Digests { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> IncludeTags { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<LabelSelector> Labels { get; } = ImmutableArray<LabelSelector>.Empty;
|
||||
|
||||
public bool ResolvesTags { get; }
|
||||
|
||||
private static ImmutableArray<LabelSelector> NormalizeLabels(IEnumerable<LabelSelector>? labels)
|
||||
{
|
||||
if (labels is null)
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
return labels
|
||||
.Where(static label => label is not null)
|
||||
.Select(static label => label!)
|
||||
.OrderBy(static label => label.Key, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a label match (key and optional accepted values).
|
||||
/// </summary>
|
||||
public sealed record LabelSelector
|
||||
{
|
||||
public LabelSelector(string key, IEnumerable<string>? values = null)
|
||||
: this(key, NormalizeValues(values))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public LabelSelector(string key, ImmutableArray<string> values)
|
||||
{
|
||||
Key = Validation.EnsureSimpleIdentifier(key, nameof(key));
|
||||
Values = values.IsDefault ? ImmutableArray<string>.Empty : values;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Values { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
private static ImmutableArray<string> NormalizeValues(IEnumerable<string>? values)
|
||||
=> Validation.NormalizeStringSet(values, nameof(values));
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Selector filters used to resolve impacted assets.
|
||||
/// </summary>
|
||||
public sealed record Selector
|
||||
{
|
||||
public Selector(
|
||||
SelectorScope scope,
|
||||
string? tenantId = null,
|
||||
IEnumerable<string>? namespaces = null,
|
||||
IEnumerable<string>? repositories = null,
|
||||
IEnumerable<string>? digests = null,
|
||||
IEnumerable<string>? includeTags = null,
|
||||
IEnumerable<LabelSelector>? labels = null,
|
||||
bool resolvesTags = false)
|
||||
: this(
|
||||
scope,
|
||||
tenantId,
|
||||
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
|
||||
Validation.NormalizeStringSet(repositories, nameof(repositories)),
|
||||
Validation.NormalizeDigests(digests, nameof(digests)),
|
||||
Validation.NormalizeTagPatterns(includeTags),
|
||||
NormalizeLabels(labels),
|
||||
resolvesTags)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Selector(
|
||||
SelectorScope scope,
|
||||
string? tenantId,
|
||||
ImmutableArray<string> namespaces,
|
||||
ImmutableArray<string> repositories,
|
||||
ImmutableArray<string> digests,
|
||||
ImmutableArray<string> includeTags,
|
||||
ImmutableArray<LabelSelector> labels,
|
||||
bool resolvesTags)
|
||||
{
|
||||
Scope = scope;
|
||||
TenantId = tenantId is null ? null : Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
|
||||
Repositories = repositories.IsDefault ? ImmutableArray<string>.Empty : repositories;
|
||||
Digests = digests.IsDefault ? ImmutableArray<string>.Empty : digests;
|
||||
IncludeTags = includeTags.IsDefault ? ImmutableArray<string>.Empty : includeTags;
|
||||
Labels = labels.IsDefault ? ImmutableArray<LabelSelector>.Empty : labels;
|
||||
ResolvesTags = resolvesTags;
|
||||
|
||||
if (Scope is SelectorScope.ByDigest && Digests.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one digest is required when scope is by-digest.", nameof(digests));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByNamespace && Namespaces.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Namespaces are required when scope is by-namespace.", nameof(namespaces));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByRepository && Repositories.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Repositories are required when scope is by-repo.", nameof(repositories));
|
||||
}
|
||||
|
||||
if (Scope is SelectorScope.ByLabels && Labels.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Labels are required when scope is by-labels.", nameof(labels));
|
||||
}
|
||||
}
|
||||
|
||||
public SelectorScope Scope { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Repositories { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Digests { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> IncludeTags { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<LabelSelector> Labels { get; } = ImmutableArray<LabelSelector>.Empty;
|
||||
|
||||
public bool ResolvesTags { get; }
|
||||
|
||||
private static ImmutableArray<LabelSelector> NormalizeLabels(IEnumerable<LabelSelector>? labels)
|
||||
{
|
||||
if (labels is null)
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
return labels
|
||||
.Where(static label => label is not null)
|
||||
.Select(static label => label!)
|
||||
.OrderBy(static label => label.Key, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a label match (key and optional accepted values).
|
||||
/// </summary>
|
||||
public sealed record LabelSelector
|
||||
{
|
||||
public LabelSelector(string key, IEnumerable<string>? values = null)
|
||||
: this(key, NormalizeValues(values))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public LabelSelector(string key, ImmutableArray<string> values)
|
||||
{
|
||||
Key = Validation.EnsureSimpleIdentifier(key, nameof(key));
|
||||
Values = values.IsDefault ? ImmutableArray<string>.Empty : values;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> Values { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
private static ImmutableArray<string> NormalizeValues(IEnumerable<string>? values)
|
||||
=> Validation.NormalizeStringSet(values, nameof(values));
|
||||
}
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation helpers for scheduler DTO constructors.
|
||||
/// </summary>
|
||||
internal static partial class Validation
|
||||
{
|
||||
private const int MaxIdentifierLength = 256;
|
||||
private const int MaxNameLength = 200;
|
||||
|
||||
public static string EnsureId(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > MaxIdentifierLength)
|
||||
{
|
||||
throw new ArgumentException($"Value exceeds {MaxIdentifierLength} characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureName(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > MaxNameLength)
|
||||
{
|
||||
throw new ArgumentException($"Value exceeds {MaxNameLength} characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureTenantId(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureId(value, paramName);
|
||||
if (!TenantRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Tenant id must be alphanumeric with '-', '_' separators.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureCronExpression(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > 128 || normalized.Contains('\n', StringComparison.Ordinal) || normalized.Contains('\r', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Cron expression too long or contains invalid characters.", paramName);
|
||||
}
|
||||
|
||||
if (!CronSegmentRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Cron expression contains unsupported characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureTimezone(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
try
|
||||
{
|
||||
_ = TimeZoneInfo.FindSystemTimeZoneById(normalized);
|
||||
}
|
||||
catch (TimeZoneNotFoundException ex)
|
||||
{
|
||||
throw new ArgumentException($"Timezone '{normalized}' is not recognized on this host.", paramName, ex);
|
||||
}
|
||||
catch (InvalidTimeZoneException ex)
|
||||
{
|
||||
throw new ArgumentException($"Timezone '{normalized}' is invalid.", paramName, ex);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Trim();
|
||||
|
||||
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values, string paramName, bool allowWildcards = false)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(value => allowWildcards ? value! : EnsureSimpleIdentifier(value!, paramName))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ImmutableArray<string> NormalizeTagPatterns(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ImmutableArray<string> NormalizeDigests(IEnumerable<string>? values, string paramName)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(value => EnsureDigestFormat(value!, paramName))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int? EnsurePositiveOrNull(int? value, string paramName)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Value must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static int EnsureNonNegative(int value, string paramName)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Value must be zero or greater.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static ImmutableSortedDictionary<string, string> NormalizeMetadata(IEnumerable<KeyValuePair<string, string>>? metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
var key = TrimToNull(pair.Key);
|
||||
var value = TrimToNull(pair.Value);
|
||||
if (key is null || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.ToLowerInvariant();
|
||||
if (!builder.ContainsKey(normalizedKey))
|
||||
{
|
||||
builder[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
public static string EnsureSimpleIdentifier(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (!SimpleIdentifierRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Value must contain letters, digits, '-', '_', '.', or '/'.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureDigestFormat(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName).ToLowerInvariant();
|
||||
if (!normalized.StartsWith("sha256:", StringComparison.Ordinal) || normalized.Length <= 7)
|
||||
{
|
||||
throw new ArgumentException("Digest must start with 'sha256:' and contain a hex payload.", paramName);
|
||||
}
|
||||
|
||||
if (!HexRegex().IsMatch(normalized.AsSpan(7)))
|
||||
{
|
||||
throw new ArgumentException("Digest must be hexadecimal.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static DateTimeOffset NormalizeTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime();
|
||||
|
||||
public static DateTimeOffset? NormalizeTimestamp(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9_-]+$")]
|
||||
private static partial Regex TenantRegex();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9_./:@+\\-]+$")]
|
||||
private static partial Regex SimpleIdentifierRegex();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9:*?/_.,\\- ]+$")]
|
||||
private static partial Regex CronSegmentRegex();
|
||||
|
||||
[GeneratedRegex("^[a-f0-9]+$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HexRegex();
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation helpers for scheduler DTO constructors.
|
||||
/// </summary>
|
||||
internal static partial class Validation
|
||||
{
|
||||
private const int MaxIdentifierLength = 256;
|
||||
private const int MaxNameLength = 200;
|
||||
|
||||
public static string EnsureId(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > MaxIdentifierLength)
|
||||
{
|
||||
throw new ArgumentException($"Value exceeds {MaxIdentifierLength} characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureName(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > MaxNameLength)
|
||||
{
|
||||
throw new ArgumentException($"Value exceeds {MaxNameLength} characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureTenantId(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureId(value, paramName);
|
||||
if (!TenantRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Tenant id must be alphanumeric with '-', '_' separators.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureCronExpression(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (normalized.Length > 128 || normalized.Contains('\n', StringComparison.Ordinal) || normalized.Contains('\r', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Cron expression too long or contains invalid characters.", paramName);
|
||||
}
|
||||
|
||||
if (!CronSegmentRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Cron expression contains unsupported characters.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureTimezone(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
try
|
||||
{
|
||||
_ = TimeZoneInfo.FindSystemTimeZoneById(normalized);
|
||||
}
|
||||
catch (TimeZoneNotFoundException ex)
|
||||
{
|
||||
throw new ArgumentException($"Timezone '{normalized}' is not recognized on this host.", paramName, ex);
|
||||
}
|
||||
catch (InvalidTimeZoneException ex)
|
||||
{
|
||||
throw new ArgumentException($"Timezone '{normalized}' is invalid.", paramName, ex);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Trim();
|
||||
|
||||
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values, string paramName, bool allowWildcards = false)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(value => allowWildcards ? value! : EnsureSimpleIdentifier(value!, paramName))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ImmutableArray<string> NormalizeTagPatterns(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ImmutableArray<string> NormalizeDigests(IEnumerable<string>? values, string paramName)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var result = values
|
||||
.Select(static value => TrimToNull(value))
|
||||
.Where(static value => value is not null)
|
||||
.Select(value => EnsureDigestFormat(value!, paramName))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int? EnsurePositiveOrNull(int? value, string paramName)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Value must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static int EnsureNonNegative(int value, string paramName)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Value must be zero or greater.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static ImmutableSortedDictionary<string, string> NormalizeMetadata(IEnumerable<KeyValuePair<string, string>>? metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
var key = TrimToNull(pair.Key);
|
||||
var value = TrimToNull(pair.Value);
|
||||
if (key is null || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.ToLowerInvariant();
|
||||
if (!builder.ContainsKey(normalizedKey))
|
||||
{
|
||||
builder[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
public static string EnsureSimpleIdentifier(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
|
||||
if (!SimpleIdentifierRegex().IsMatch(normalized))
|
||||
{
|
||||
throw new ArgumentException("Value must contain letters, digits, '-', '_', '.', or '/'.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureDigestFormat(string value, string paramName)
|
||||
{
|
||||
var normalized = EnsureNotNullOrWhiteSpace(value, paramName).ToLowerInvariant();
|
||||
if (!normalized.StartsWith("sha256:", StringComparison.Ordinal) || normalized.Length <= 7)
|
||||
{
|
||||
throw new ArgumentException("Digest must start with 'sha256:' and contain a hex payload.", paramName);
|
||||
}
|
||||
|
||||
if (!HexRegex().IsMatch(normalized.AsSpan(7)))
|
||||
{
|
||||
throw new ArgumentException("Digest must be hexadecimal.", paramName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static DateTimeOffset NormalizeTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime();
|
||||
|
||||
public static DateTimeOffset? NormalizeTimestamp(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9_-]+$")]
|
||||
private static partial Regex TenantRegex();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9_./:@+\\-]+$")]
|
||||
private static partial Regex SimpleIdentifierRegex();
|
||||
|
||||
[GeneratedRegex("^[A-Za-z0-9:*?/_.,\\- ]+$")]
|
||||
private static partial Regex CronSegmentRegex();
|
||||
|
||||
[GeneratedRegex("^[a-f0-9]+$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HexRegex();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.Queue.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.Queue.Tests")]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
internal interface ISchedulerQueueTransportDiagnostics
|
||||
{
|
||||
ValueTask PingAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
internal interface ISchedulerQueueTransportDiagnostics
|
||||
{
|
||||
ValueTask PingAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal interface INatsSchedulerQueuePayload<TMessage>
|
||||
{
|
||||
string QueueName { get; }
|
||||
|
||||
string GetIdempotencyKey(TMessage message);
|
||||
|
||||
byte[] Serialize(TMessage message);
|
||||
|
||||
TMessage Deserialize(byte[] payload);
|
||||
|
||||
string GetRunId(TMessage message);
|
||||
|
||||
string GetTenantId(TMessage message);
|
||||
|
||||
string? GetScheduleId(TMessage message);
|
||||
|
||||
string? GetSegmentId(TMessage message);
|
||||
|
||||
string? GetCorrelationId(TMessage message);
|
||||
|
||||
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal interface INatsSchedulerQueuePayload<TMessage>
|
||||
{
|
||||
string QueueName { get; }
|
||||
|
||||
string GetIdempotencyKey(TMessage message);
|
||||
|
||||
byte[] Serialize(TMessage message);
|
||||
|
||||
TMessage Deserialize(byte[] payload);
|
||||
|
||||
string GetRunId(TMessage message);
|
||||
|
||||
string GetTenantId(TMessage message);
|
||||
|
||||
string? GetScheduleId(TMessage message);
|
||||
|
||||
string? GetSegmentId(TMessage message);
|
||||
|
||||
string? GetCorrelationId(TMessage message);
|
||||
|
||||
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerPlannerQueue
|
||||
: NatsSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
|
||||
{
|
||||
public NatsSchedulerPlannerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerNatsQueueOptions natsOptions,
|
||||
ILogger<NatsSchedulerPlannerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
natsOptions,
|
||||
natsOptions.Planner,
|
||||
PlannerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class PlannerPayload : INatsSchedulerQueuePayload<PlannerQueueMessage>
|
||||
{
|
||||
public static PlannerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "planner";
|
||||
|
||||
public string GetIdempotencyKey(PlannerQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public byte[] Serialize(PlannerQueueMessage message)
|
||||
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
|
||||
|
||||
public PlannerQueueMessage Deserialize(byte[] payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(Encoding.UTF8.GetString(payload));
|
||||
|
||||
public string GetRunId(PlannerQueueMessage message)
|
||||
=> message.Run.Id;
|
||||
|
||||
public string GetTenantId(PlannerQueueMessage message)
|
||||
=> message.Run.TenantId;
|
||||
|
||||
public string? GetScheduleId(PlannerQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(PlannerQueueMessage message)
|
||||
=> null;
|
||||
|
||||
public string? GetCorrelationId(PlannerQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
|
||||
=> null;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerPlannerQueue
|
||||
: NatsSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
|
||||
{
|
||||
public NatsSchedulerPlannerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerNatsQueueOptions natsOptions,
|
||||
ILogger<NatsSchedulerPlannerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
natsOptions,
|
||||
natsOptions.Planner,
|
||||
PlannerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class PlannerPayload : INatsSchedulerQueuePayload<PlannerQueueMessage>
|
||||
{
|
||||
public static PlannerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "planner";
|
||||
|
||||
public string GetIdempotencyKey(PlannerQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public byte[] Serialize(PlannerQueueMessage message)
|
||||
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
|
||||
|
||||
public PlannerQueueMessage Deserialize(byte[] payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(Encoding.UTF8.GetString(payload));
|
||||
|
||||
public string GetRunId(PlannerQueueMessage message)
|
||||
=> message.Run.Id;
|
||||
|
||||
public string GetTenantId(PlannerQueueMessage message)
|
||||
=> message.Run.TenantId;
|
||||
|
||||
public string? GetScheduleId(PlannerQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(PlannerQueueMessage message)
|
||||
=> null;
|
||||
|
||||
public string? GetCorrelationId(PlannerQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
|
||||
=> null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
|
||||
{
|
||||
private readonly NatsSchedulerQueueBase<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal NatsSchedulerQueueLease(
|
||||
NatsSchedulerQueueBase<TMessage> queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
byte[] payload,
|
||||
string idempotencyKey,
|
||||
string runId,
|
||||
string tenantId,
|
||||
string? scheduleId,
|
||||
string? segmentId,
|
||||
string? correlationId,
|
||||
IReadOnlyDictionary<string, string> attributes,
|
||||
TMessage deserialized,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = message.Metadata?.Sequence.ToString() ?? idempotencyKey;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = scheduleId;
|
||||
SegmentId = segmentId;
|
||||
CorrelationId = correlationId;
|
||||
Attributes = attributes;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
IdempotencyKey = idempotencyKey;
|
||||
Message = deserialized;
|
||||
_message = message;
|
||||
Payload = payload;
|
||||
}
|
||||
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
internal byte[] Payload { get; }
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public string? SegmentId { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
public TMessage Message { get; }
|
||||
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NATS.Client.JetStream;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
|
||||
{
|
||||
private readonly NatsSchedulerQueueBase<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal NatsSchedulerQueueLease(
|
||||
NatsSchedulerQueueBase<TMessage> queue,
|
||||
NatsJSMsg<byte[]> message,
|
||||
byte[] payload,
|
||||
string idempotencyKey,
|
||||
string runId,
|
||||
string tenantId,
|
||||
string? scheduleId,
|
||||
string? segmentId,
|
||||
string? correlationId,
|
||||
IReadOnlyDictionary<string, string> attributes,
|
||||
TMessage deserialized,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = message.Metadata?.Sequence.ToString() ?? idempotencyKey;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = scheduleId;
|
||||
SegmentId = segmentId;
|
||||
CorrelationId = correlationId;
|
||||
Attributes = attributes;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
IdempotencyKey = idempotencyKey;
|
||||
Message = deserialized;
|
||||
_message = message;
|
||||
Payload = payload;
|
||||
}
|
||||
|
||||
private readonly NatsJSMsg<byte[]> _message;
|
||||
|
||||
internal NatsJSMsg<byte[]> RawMessage => _message;
|
||||
|
||||
internal byte[] Payload { get; }
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public string? SegmentId { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
public TMessage Message { get; }
|
||||
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerRunnerQueue
|
||||
: NatsSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
|
||||
{
|
||||
public NatsSchedulerRunnerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerNatsQueueOptions natsOptions,
|
||||
ILogger<NatsSchedulerRunnerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
natsOptions,
|
||||
natsOptions.Runner,
|
||||
RunnerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class RunnerPayload : INatsSchedulerQueuePayload<RunnerSegmentQueueMessage>
|
||||
{
|
||||
public static RunnerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "runner";
|
||||
|
||||
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public byte[] Serialize(RunnerSegmentQueueMessage message)
|
||||
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
|
||||
|
||||
public RunnerSegmentQueueMessage Deserialize(byte[] payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(Encoding.UTF8.GetString(payload));
|
||||
|
||||
public string GetRunId(RunnerSegmentQueueMessage message)
|
||||
=> message.RunId;
|
||||
|
||||
public string GetTenantId(RunnerSegmentQueueMessage message)
|
||||
=> message.TenantId;
|
||||
|
||||
public string? GetScheduleId(RunnerSegmentQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(RunnerSegmentQueueMessage message)
|
||||
=> message.SegmentId;
|
||||
|
||||
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
|
||||
{
|
||||
if (message.Attributes is null || message.Attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return message.Attributes.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Nats;
|
||||
|
||||
internal sealed class NatsSchedulerRunnerQueue
|
||||
: NatsSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
|
||||
{
|
||||
public NatsSchedulerRunnerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerNatsQueueOptions natsOptions,
|
||||
ILogger<NatsSchedulerRunnerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
natsOptions,
|
||||
natsOptions.Runner,
|
||||
RunnerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class RunnerPayload : INatsSchedulerQueuePayload<RunnerSegmentQueueMessage>
|
||||
{
|
||||
public static RunnerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "runner";
|
||||
|
||||
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public byte[] Serialize(RunnerSegmentQueueMessage message)
|
||||
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
|
||||
|
||||
public RunnerSegmentQueueMessage Deserialize(byte[] payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(Encoding.UTF8.GetString(payload));
|
||||
|
||||
public string GetRunId(RunnerSegmentQueueMessage message)
|
||||
=> message.RunId;
|
||||
|
||||
public string GetTenantId(RunnerSegmentQueueMessage message)
|
||||
=> message.TenantId;
|
||||
|
||||
public string? GetScheduleId(RunnerSegmentQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(RunnerSegmentQueueMessage message)
|
||||
=> message.SegmentId;
|
||||
|
||||
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
|
||||
{
|
||||
if (message.Attributes is null || message.Attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return message.Attributes.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal interface IRedisSchedulerQueuePayload<TMessage>
|
||||
{
|
||||
string QueueName { get; }
|
||||
|
||||
string GetIdempotencyKey(TMessage message);
|
||||
|
||||
string Serialize(TMessage message);
|
||||
|
||||
TMessage Deserialize(string payload);
|
||||
|
||||
string GetRunId(TMessage message);
|
||||
|
||||
string GetTenantId(TMessage message);
|
||||
|
||||
string? GetScheduleId(TMessage message);
|
||||
|
||||
string? GetSegmentId(TMessage message);
|
||||
|
||||
string? GetCorrelationId(TMessage message);
|
||||
|
||||
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal interface IRedisSchedulerQueuePayload<TMessage>
|
||||
{
|
||||
string QueueName { get; }
|
||||
|
||||
string GetIdempotencyKey(TMessage message);
|
||||
|
||||
string Serialize(TMessage message);
|
||||
|
||||
TMessage Deserialize(string payload);
|
||||
|
||||
string GetRunId(TMessage message);
|
||||
|
||||
string GetTenantId(TMessage message);
|
||||
|
||||
string? GetScheduleId(TMessage message);
|
||||
|
||||
string? GetSegmentId(TMessage message);
|
||||
|
||||
string? GetCorrelationId(TMessage message);
|
||||
|
||||
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerPlannerQueue
|
||||
: RedisSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
|
||||
{
|
||||
public RedisSchedulerPlannerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerRedisQueueOptions redisOptions,
|
||||
ILogger<RedisSchedulerPlannerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
redisOptions,
|
||||
redisOptions.Planner,
|
||||
PlannerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class PlannerPayload : IRedisSchedulerQueuePayload<PlannerQueueMessage>
|
||||
{
|
||||
public static PlannerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "planner";
|
||||
|
||||
public string GetIdempotencyKey(PlannerQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public string Serialize(PlannerQueueMessage message)
|
||||
=> CanonicalJsonSerializer.Serialize(message);
|
||||
|
||||
public PlannerQueueMessage Deserialize(string payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(payload);
|
||||
|
||||
public string GetRunId(PlannerQueueMessage message)
|
||||
=> message.Run.Id;
|
||||
|
||||
public string GetTenantId(PlannerQueueMessage message)
|
||||
=> message.Run.TenantId;
|
||||
|
||||
public string? GetScheduleId(PlannerQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(PlannerQueueMessage message)
|
||||
=> null;
|
||||
|
||||
public string? GetCorrelationId(PlannerQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
|
||||
=> null;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerPlannerQueue
|
||||
: RedisSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
|
||||
{
|
||||
public RedisSchedulerPlannerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerRedisQueueOptions redisOptions,
|
||||
ILogger<RedisSchedulerPlannerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
redisOptions,
|
||||
redisOptions.Planner,
|
||||
PlannerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class PlannerPayload : IRedisSchedulerQueuePayload<PlannerQueueMessage>
|
||||
{
|
||||
public static PlannerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "planner";
|
||||
|
||||
public string GetIdempotencyKey(PlannerQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public string Serialize(PlannerQueueMessage message)
|
||||
=> CanonicalJsonSerializer.Serialize(message);
|
||||
|
||||
public PlannerQueueMessage Deserialize(string payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(payload);
|
||||
|
||||
public string GetRunId(PlannerQueueMessage message)
|
||||
=> message.Run.Id;
|
||||
|
||||
public string GetTenantId(PlannerQueueMessage message)
|
||||
=> message.Run.TenantId;
|
||||
|
||||
public string? GetScheduleId(PlannerQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(PlannerQueueMessage message)
|
||||
=> null;
|
||||
|
||||
public string? GetCorrelationId(PlannerQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
|
||||
=> null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,91 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
|
||||
{
|
||||
private readonly RedisSchedulerQueueBase<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisSchedulerQueueLease(
|
||||
RedisSchedulerQueueBase<TMessage> queue,
|
||||
string messageId,
|
||||
string idempotencyKey,
|
||||
string runId,
|
||||
string tenantId,
|
||||
string? scheduleId,
|
||||
string? segmentId,
|
||||
string? correlationId,
|
||||
IReadOnlyDictionary<string, string> attributes,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
IdempotencyKey = idempotencyKey;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = scheduleId;
|
||||
SegmentId = segmentId;
|
||||
CorrelationId = correlationId;
|
||||
Attributes = attributes;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public string? SegmentId { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
public TMessage Message { get; }
|
||||
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
|
||||
{
|
||||
private readonly RedisSchedulerQueueBase<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal RedisSchedulerQueueLease(
|
||||
RedisSchedulerQueueBase<TMessage> queue,
|
||||
string messageId,
|
||||
string idempotencyKey,
|
||||
string runId,
|
||||
string tenantId,
|
||||
string? scheduleId,
|
||||
string? segmentId,
|
||||
string? correlationId,
|
||||
IReadOnlyDictionary<string, string> attributes,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
IdempotencyKey = idempotencyKey;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = scheduleId;
|
||||
SegmentId = segmentId;
|
||||
CorrelationId = correlationId;
|
||||
Attributes = attributes;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string IdempotencyKey { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public string? SegmentId { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
public TMessage Message { get; }
|
||||
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
|
||||
|
||||
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerRunnerQueue
|
||||
: RedisSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
|
||||
{
|
||||
public RedisSchedulerRunnerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerRedisQueueOptions redisOptions,
|
||||
ILogger<RedisSchedulerRunnerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
redisOptions,
|
||||
redisOptions.Runner,
|
||||
RunnerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class RunnerPayload : IRedisSchedulerQueuePayload<RunnerSegmentQueueMessage>
|
||||
{
|
||||
public static RunnerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "runner";
|
||||
|
||||
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public string Serialize(RunnerSegmentQueueMessage message)
|
||||
=> CanonicalJsonSerializer.Serialize(message);
|
||||
|
||||
public RunnerSegmentQueueMessage Deserialize(string payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(payload);
|
||||
|
||||
public string GetRunId(RunnerSegmentQueueMessage message)
|
||||
=> message.RunId;
|
||||
|
||||
public string GetTenantId(RunnerSegmentQueueMessage message)
|
||||
=> message.TenantId;
|
||||
|
||||
public string? GetScheduleId(RunnerSegmentQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(RunnerSegmentQueueMessage message)
|
||||
=> message.SegmentId;
|
||||
|
||||
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
|
||||
{
|
||||
if (message.Attributes.Count == 0 && message.ImageDigests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure digests remain accessible without deserializing the entire payload.
|
||||
var map = new Dictionary<string, string>(message.Attributes, StringComparer.Ordinal);
|
||||
map["imageDigestCount"] = message.ImageDigests.Count.ToString();
|
||||
|
||||
// populate first few digests for quick inspection (bounded)
|
||||
var take = Math.Min(message.ImageDigests.Count, 5);
|
||||
for (var i = 0; i < take; i++)
|
||||
{
|
||||
map[$"digest{i}"] = message.ImageDigests[i];
|
||||
}
|
||||
|
||||
if (message.RatePerSecond.HasValue)
|
||||
{
|
||||
map["ratePerSecond"] = message.RatePerSecond.Value.ToString();
|
||||
}
|
||||
|
||||
map["usageOnly"] = message.UsageOnly ? "true" : "false";
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Redis;
|
||||
|
||||
internal sealed class RedisSchedulerRunnerQueue
|
||||
: RedisSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
|
||||
{
|
||||
public RedisSchedulerRunnerQueue(
|
||||
SchedulerQueueOptions queueOptions,
|
||||
SchedulerRedisQueueOptions redisOptions,
|
||||
ILogger<RedisSchedulerRunnerQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
: base(
|
||||
queueOptions,
|
||||
redisOptions,
|
||||
redisOptions.Runner,
|
||||
RunnerPayload.Instance,
|
||||
logger,
|
||||
timeProvider,
|
||||
connectionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class RunnerPayload : IRedisSchedulerQueuePayload<RunnerSegmentQueueMessage>
|
||||
{
|
||||
public static RunnerPayload Instance { get; } = new();
|
||||
|
||||
public string QueueName => "runner";
|
||||
|
||||
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
|
||||
=> message.IdempotencyKey;
|
||||
|
||||
public string Serialize(RunnerSegmentQueueMessage message)
|
||||
=> CanonicalJsonSerializer.Serialize(message);
|
||||
|
||||
public RunnerSegmentQueueMessage Deserialize(string payload)
|
||||
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(payload);
|
||||
|
||||
public string GetRunId(RunnerSegmentQueueMessage message)
|
||||
=> message.RunId;
|
||||
|
||||
public string GetTenantId(RunnerSegmentQueueMessage message)
|
||||
=> message.TenantId;
|
||||
|
||||
public string? GetScheduleId(RunnerSegmentQueueMessage message)
|
||||
=> message.ScheduleId;
|
||||
|
||||
public string? GetSegmentId(RunnerSegmentQueueMessage message)
|
||||
=> message.SegmentId;
|
||||
|
||||
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
|
||||
=> message.CorrelationId;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
|
||||
{
|
||||
if (message.Attributes.Count == 0 && message.ImageDigests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure digests remain accessible without deserializing the entire payload.
|
||||
var map = new Dictionary<string, string>(message.Attributes, StringComparer.Ordinal);
|
||||
map["imageDigestCount"] = message.ImageDigests.Count.ToString();
|
||||
|
||||
// populate first few digests for quick inspection (bounded)
|
||||
var take = Math.Min(message.ImageDigests.Count, 5);
|
||||
for (var i = 0; i < take; i++)
|
||||
{
|
||||
map[$"digest{i}"] = message.ImageDigests[i];
|
||||
}
|
||||
|
||||
if (message.RatePerSecond.HasValue)
|
||||
{
|
||||
map["ratePerSecond"] = message.RatePerSecond.Value.ToString();
|
||||
}
|
||||
|
||||
map["usageOnly"] = message.UsageOnly ? "true" : "false";
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public sealed class PlannerQueueMessage
|
||||
{
|
||||
[JsonConstructor]
|
||||
public PlannerQueueMessage(
|
||||
Run run,
|
||||
ImpactSet impactSet,
|
||||
Schedule? schedule = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
Run = run ?? throw new ArgumentNullException(nameof(run));
|
||||
ImpactSet = impactSet ?? throw new ArgumentNullException(nameof(impactSet));
|
||||
|
||||
if (schedule is not null && string.IsNullOrWhiteSpace(schedule.Id))
|
||||
{
|
||||
throw new ArgumentException("Schedule must have a valid identifier.", nameof(schedule));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
correlationId = correlationId!.Trim();
|
||||
}
|
||||
|
||||
Schedule = schedule;
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
|
||||
}
|
||||
|
||||
public Run Run { get; }
|
||||
|
||||
public ImpactSet ImpactSet { get; }
|
||||
|
||||
public Schedule? Schedule { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public string IdempotencyKey => Run.Id;
|
||||
|
||||
public string TenantId => Run.TenantId;
|
||||
|
||||
public string? ScheduleId => Run.ScheduleId;
|
||||
}
|
||||
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public sealed class PlannerQueueMessage
|
||||
{
|
||||
[JsonConstructor]
|
||||
public PlannerQueueMessage(
|
||||
Run run,
|
||||
ImpactSet impactSet,
|
||||
Schedule? schedule = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
Run = run ?? throw new ArgumentNullException(nameof(run));
|
||||
ImpactSet = impactSet ?? throw new ArgumentNullException(nameof(impactSet));
|
||||
|
||||
if (schedule is not null && string.IsNullOrWhiteSpace(schedule.Id))
|
||||
{
|
||||
throw new ArgumentException("Schedule must have a valid identifier.", nameof(schedule));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
correlationId = correlationId!.Trim();
|
||||
}
|
||||
|
||||
Schedule = schedule;
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
|
||||
}
|
||||
|
||||
public Run Run { get; }
|
||||
|
||||
public ImpactSet ImpactSet { get; }
|
||||
|
||||
public Schedule? Schedule { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public string IdempotencyKey => Run.Id;
|
||||
|
||||
public string TenantId => Run.TenantId;
|
||||
|
||||
public string? ScheduleId => Run.ScheduleId;
|
||||
}
|
||||
|
||||
public sealed class RunnerSegmentQueueMessage
|
||||
{
|
||||
private readonly ReadOnlyCollection<string> _imageDigests;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
private readonly IReadOnlyDictionary<string, SurfaceManifestPointer> _surfaceManifests;
|
||||
|
||||
[JsonConstructor]
|
||||
public RunnerSegmentQueueMessage(
|
||||
string segmentId,
|
||||
string runId,
|
||||
string tenantId,
|
||||
IReadOnlyList<string> imageDigests,
|
||||
|
||||
[JsonConstructor]
|
||||
public RunnerSegmentQueueMessage(
|
||||
string segmentId,
|
||||
string runId,
|
||||
string tenantId,
|
||||
IReadOnlyList<string> imageDigests,
|
||||
string? scheduleId = null,
|
||||
int? ratePerSecond = null,
|
||||
bool usageOnly = true,
|
||||
@@ -68,26 +68,26 @@ public sealed class RunnerSegmentQueueMessage
|
||||
string? correlationId = null,
|
||||
IReadOnlyDictionary<string, SurfaceManifestPointer>? surfaceManifests = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segmentId))
|
||||
{
|
||||
throw new ArgumentException("Segment identifier must be provided.", nameof(segmentId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Run identifier must be provided.", nameof(runId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId));
|
||||
}
|
||||
|
||||
SegmentId = segmentId;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId;
|
||||
RatePerSecond = ratePerSecond;
|
||||
if (string.IsNullOrWhiteSpace(segmentId))
|
||||
{
|
||||
throw new ArgumentException("Segment identifier must be provided.", nameof(segmentId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Run identifier must be provided.", nameof(runId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId));
|
||||
}
|
||||
|
||||
SegmentId = segmentId;
|
||||
RunId = runId;
|
||||
TenantId = tenantId;
|
||||
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId;
|
||||
RatePerSecond = ratePerSecond;
|
||||
UsageOnly = usageOnly;
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
|
||||
|
||||
@@ -99,121 +99,121 @@ public sealed class RunnerSegmentQueueMessage
|
||||
? EmptyReadOnlyDictionary<string, SurfaceManifestPointer>.Instance
|
||||
: new ReadOnlyDictionary<string, SurfaceManifestPointer>(new Dictionary<string, SurfaceManifestPointer>(surfaceManifests, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public string SegmentId { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public int? RatePerSecond { get; }
|
||||
|
||||
public bool UsageOnly { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyList<string> ImageDigests => _imageDigests;
|
||||
|
||||
|
||||
public string SegmentId { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public int? RatePerSecond { get; }
|
||||
|
||||
public bool UsageOnly { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
public IReadOnlyList<string> ImageDigests => _imageDigests;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public IReadOnlyDictionary<string, SurfaceManifestPointer> SurfaceManifests => _surfaceManifests;
|
||||
|
||||
public string IdempotencyKey => SegmentId;
|
||||
|
||||
private static List<string> NormalizeDigests(IReadOnlyList<string> digests)
|
||||
{
|
||||
if (digests is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(digests));
|
||||
}
|
||||
|
||||
var list = new List<string>();
|
||||
foreach (var digest in digests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(digest.Trim());
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one image digest must be provided.", nameof(digests));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private sealed class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
|
||||
public string IdempotencyKey => SegmentId;
|
||||
|
||||
private static List<string> NormalizeDigests(IReadOnlyList<string> digests)
|
||||
{
|
||||
if (digests is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(digests));
|
||||
}
|
||||
|
||||
var list = new List<string>();
|
||||
foreach (var digest in digests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(digest.Trim());
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one image digest must be provided.", nameof(digests));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private sealed class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
|
||||
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct SchedulerQueueEnqueueResult(string MessageId, bool Deduplicated);
|
||||
|
||||
public sealed class SchedulerQueueLeaseRequest
|
||||
{
|
||||
public SchedulerQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer identifier must be provided.", nameof(consumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (leaseDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
|
||||
}
|
||||
|
||||
Consumer = consumer;
|
||||
BatchSize = batchSize;
|
||||
LeaseDuration = leaseDuration;
|
||||
}
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
}
|
||||
|
||||
public sealed class SchedulerQueueClaimOptions
|
||||
{
|
||||
public SchedulerQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claimantConsumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer identifier must be provided.", nameof(claimantConsumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (minIdleTime < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Idle time cannot be negative.");
|
||||
}
|
||||
|
||||
ClaimantConsumer = claimantConsumer;
|
||||
BatchSize = batchSize;
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
|
||||
public sealed class SchedulerQueueLeaseRequest
|
||||
{
|
||||
public SchedulerQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer identifier must be provided.", nameof(consumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (leaseDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
|
||||
}
|
||||
|
||||
Consumer = consumer;
|
||||
BatchSize = batchSize;
|
||||
LeaseDuration = leaseDuration;
|
||||
}
|
||||
|
||||
public string Consumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
}
|
||||
|
||||
public sealed class SchedulerQueueClaimOptions
|
||||
{
|
||||
public SchedulerQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claimantConsumer))
|
||||
{
|
||||
throw new ArgumentException("Consumer identifier must be provided.", nameof(claimantConsumer));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
|
||||
}
|
||||
|
||||
if (minIdleTime < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Idle time cannot be negative.");
|
||||
}
|
||||
|
||||
ClaimantConsumer = claimantConsumer;
|
||||
BatchSize = batchSize;
|
||||
MinIdleTime = minIdleTime;
|
||||
}
|
||||
|
||||
public string ClaimantConsumer { get; }
|
||||
|
||||
public int BatchSize { get; }
|
||||
@@ -240,63 +240,63 @@ public sealed record SurfaceManifestPointer
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
public enum SchedulerQueueReleaseDisposition
|
||||
{
|
||||
Retry,
|
||||
Abandon
|
||||
}
|
||||
|
||||
public interface ISchedulerQueue<TMessage>
|
||||
{
|
||||
ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISchedulerQueueLease<out TMessage>
|
||||
{
|
||||
string MessageId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
string Consumer { get; }
|
||||
|
||||
string TenantId { get; }
|
||||
|
||||
string RunId { get; }
|
||||
|
||||
string? ScheduleId { get; }
|
||||
|
||||
string? SegmentId { get; }
|
||||
|
||||
string? CorrelationId { get; }
|
||||
|
||||
string IdempotencyKey { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
TMessage Message { get; }
|
||||
|
||||
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISchedulerPlannerQueue : ISchedulerQueue<PlannerQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public interface ISchedulerRunnerQueue : ISchedulerQueue<RunnerSegmentQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public enum SchedulerQueueReleaseDisposition
|
||||
{
|
||||
Retry,
|
||||
Abandon
|
||||
}
|
||||
|
||||
public interface ISchedulerQueue<TMessage>
|
||||
{
|
||||
ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISchedulerQueueLease<out TMessage>
|
||||
{
|
||||
string MessageId { get; }
|
||||
|
||||
int Attempt { get; }
|
||||
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
string Consumer { get; }
|
||||
|
||||
string TenantId { get; }
|
||||
|
||||
string RunId { get; }
|
||||
|
||||
string? ScheduleId { get; }
|
||||
|
||||
string? SegmentId { get; }
|
||||
|
||||
string? CorrelationId { get; }
|
||||
|
||||
string IdempotencyKey { get; }
|
||||
|
||||
IReadOnlyDictionary<string, string> Attributes { get; }
|
||||
|
||||
TMessage Message { get; }
|
||||
|
||||
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISchedulerPlannerQueue : ISchedulerQueue<PlannerQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
public interface ISchedulerRunnerQueue : ISchedulerQueue<RunnerSegmentQueueMessage>
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
internal static class SchedulerQueueFields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enqueuedAt";
|
||||
public const string IdempotencyKey = "idempotency";
|
||||
public const string RunId = "runId";
|
||||
public const string TenantId = "tenantId";
|
||||
public const string ScheduleId = "scheduleId";
|
||||
public const string SegmentId = "segmentId";
|
||||
public const string QueueKind = "queueKind";
|
||||
public const string CorrelationId = "correlationId";
|
||||
public const string AttributePrefix = "attr:";
|
||||
}
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
internal static class SchedulerQueueFields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enqueuedAt";
|
||||
public const string IdempotencyKey = "idempotency";
|
||||
public const string RunId = "runId";
|
||||
public const string TenantId = "tenantId";
|
||||
public const string ScheduleId = "scheduleId";
|
||||
public const string SegmentId = "segmentId";
|
||||
public const string QueueKind = "queueKind";
|
||||
public const string CorrelationId = "correlationId";
|
||||
public const string AttributePrefix = "attr:";
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public sealed class SchedulerQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ISchedulerPlannerQueue _plannerQueue;
|
||||
private readonly ISchedulerRunnerQueue _runnerQueue;
|
||||
private readonly ILogger<SchedulerQueueHealthCheck> _logger;
|
||||
|
||||
public SchedulerQueueHealthCheck(
|
||||
ISchedulerPlannerQueue plannerQueue,
|
||||
ISchedulerRunnerQueue runnerQueue,
|
||||
ILogger<SchedulerQueueHealthCheck> logger)
|
||||
{
|
||||
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
|
||||
_runnerQueue = runnerQueue ?? throw new ArgumentNullException(nameof(runnerQueue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (!await ProbeAsync(_plannerQueue, "planner", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
failures.Add("planner transport unreachable");
|
||||
}
|
||||
|
||||
if (!await ProbeAsync(_runnerQueue, "runner", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
failures.Add("runner transport unreachable");
|
||||
}
|
||||
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
return HealthCheckResult.Healthy("Scheduler queues reachable.");
|
||||
}
|
||||
|
||||
var description = string.Join("; ", failures);
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
description);
|
||||
}
|
||||
|
||||
private async Task<bool> ProbeAsync(object queue, string label, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (queue is ISchedulerQueueTransportDiagnostics diagnostics)
|
||||
{
|
||||
await diagnostics.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Scheduler {Label} queue transport ping failed.", label);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public sealed class SchedulerQueueHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ISchedulerPlannerQueue _plannerQueue;
|
||||
private readonly ISchedulerRunnerQueue _runnerQueue;
|
||||
private readonly ILogger<SchedulerQueueHealthCheck> _logger;
|
||||
|
||||
public SchedulerQueueHealthCheck(
|
||||
ISchedulerPlannerQueue plannerQueue,
|
||||
ISchedulerRunnerQueue runnerQueue,
|
||||
ILogger<SchedulerQueueHealthCheck> logger)
|
||||
{
|
||||
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
|
||||
_runnerQueue = runnerQueue ?? throw new ArgumentNullException(nameof(runnerQueue));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (!await ProbeAsync(_plannerQueue, "planner", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
failures.Add("planner transport unreachable");
|
||||
}
|
||||
|
||||
if (!await ProbeAsync(_runnerQueue, "runner", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
failures.Add("runner transport unreachable");
|
||||
}
|
||||
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
return HealthCheckResult.Healthy("Scheduler queues reachable.");
|
||||
}
|
||||
|
||||
var description = string.Join("; ", failures);
|
||||
return new HealthCheckResult(
|
||||
context.Registration.FailureStatus,
|
||||
description);
|
||||
}
|
||||
|
||||
private async Task<bool> ProbeAsync(object queue, string label, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (queue is ISchedulerQueueTransportDiagnostics diagnostics)
|
||||
{
|
||||
await diagnostics.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Scheduler {Label} queue transport ping failed.", label);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public static class SchedulerQueueMetrics
|
||||
{
|
||||
private const string TransportTagName = "transport";
|
||||
private const string QueueTagName = "queue";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.Scheduler.Queue");
|
||||
private const string TransportTagName = "transport";
|
||||
private const string QueueTagName = "queue";
|
||||
|
||||
private static readonly Meter Meter = new("StellaOps.Scheduler.Queue");
|
||||
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("scheduler_queue_enqueued_total");
|
||||
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("scheduler_queue_deduplicated_total");
|
||||
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("scheduler_queue_ack_total");
|
||||
@@ -43,16 +43,16 @@ public static class SchedulerQueueMetrics
|
||||
|
||||
public static void RecordEnqueued(string transport, string queue)
|
||||
=> EnqueuedCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordDeduplicated(string transport, string queue)
|
||||
=> DeduplicatedCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordAck(string transport, string queue)
|
||||
=> AckCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordRetry(string transport, string queue)
|
||||
=> RetryCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
|
||||
public static void RecordDeduplicated(string transport, string queue)
|
||||
=> DeduplicatedCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordAck(string transport, string queue)
|
||||
=> AckCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordRetry(string transport, string queue)
|
||||
=> RetryCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
public static void RecordDeadLetter(string transport, string queue)
|
||||
=> DeadLetterCounter.Add(1, BuildTags(transport, queue));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public sealed class SchedulerQueueOptions
|
||||
{
|
||||
public SchedulerQueueTransportKind Kind { get; set; } = SchedulerQueueTransportKind.Redis;
|
||||
@@ -30,56 +30,56 @@ public sealed class SchedulerQueueOptions
|
||||
/// Base retry delay used when a message is released for retry.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Cap applied to the retry delay when exponential backoff is used.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public sealed class SchedulerRedisQueueOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public int? Database { get; set; }
|
||||
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public RedisSchedulerStreamOptions Planner { get; set; } = RedisSchedulerStreamOptions.ForPlanner();
|
||||
|
||||
public RedisSchedulerStreamOptions Runner { get; set; } = RedisSchedulerStreamOptions.ForRunner();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Cap applied to the retry delay when exponential backoff is used.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public sealed class SchedulerRedisQueueOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public int? Database { get; set; }
|
||||
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public RedisSchedulerStreamOptions Planner { get; set; } = RedisSchedulerStreamOptions.ForPlanner();
|
||||
|
||||
public RedisSchedulerStreamOptions Runner { get; set; } = RedisSchedulerStreamOptions.ForRunner();
|
||||
}
|
||||
|
||||
public sealed class RedisSchedulerStreamOptions
|
||||
{
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
public string ConsumerGroup { get; set; } = string.Empty;
|
||||
|
||||
public string DeadLetterStream { get; set; } = string.Empty;
|
||||
|
||||
public string IdempotencyKeyPrefix { get; set; } = string.Empty;
|
||||
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public static RedisSchedulerStreamOptions ForPlanner()
|
||||
=> new()
|
||||
{
|
||||
Stream = "scheduler:planner",
|
||||
ConsumerGroup = "scheduler-planners",
|
||||
DeadLetterStream = "scheduler:planner:dead",
|
||||
IdempotencyKeyPrefix = "scheduler:planner:idemp:"
|
||||
};
|
||||
|
||||
public static RedisSchedulerStreamOptions ForRunner()
|
||||
=> new()
|
||||
{
|
||||
Stream = "scheduler:runner",
|
||||
ConsumerGroup = "scheduler-runners",
|
||||
DeadLetterStream = "scheduler:runner:dead",
|
||||
IdempotencyKeyPrefix = "scheduler:runner:idemp:"
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
public string ConsumerGroup { get; set; } = string.Empty;
|
||||
|
||||
public string DeadLetterStream { get; set; } = string.Empty;
|
||||
|
||||
public string IdempotencyKeyPrefix { get; set; } = string.Empty;
|
||||
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
public static RedisSchedulerStreamOptions ForPlanner()
|
||||
=> new()
|
||||
{
|
||||
Stream = "scheduler:planner",
|
||||
ConsumerGroup = "scheduler-planners",
|
||||
DeadLetterStream = "scheduler:planner:dead",
|
||||
IdempotencyKeyPrefix = "scheduler:planner:idemp:"
|
||||
};
|
||||
|
||||
public static RedisSchedulerStreamOptions ForRunner()
|
||||
=> new()
|
||||
{
|
||||
Stream = "scheduler:runner",
|
||||
ConsumerGroup = "scheduler-runners",
|
||||
DeadLetterStream = "scheduler:runner:dead",
|
||||
IdempotencyKeyPrefix = "scheduler:runner:idemp:"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,26 +10,26 @@ using StellaOps.Scheduler.Queue.Redis;
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
public static class SchedulerQueueServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSchedulerQueues(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "scheduler:queue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var options = new SchedulerQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(options);
|
||||
|
||||
{
|
||||
public static IServiceCollection AddSchedulerQueues(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "scheduler:queue")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var options = new SchedulerQueueOptions();
|
||||
configuration.GetSection(sectionName).Bind(options);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(options);
|
||||
|
||||
services.AddSingleton<ISchedulerPlannerQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
return options.Kind switch
|
||||
{
|
||||
SchedulerQueueTransportKind.Redis => new RedisSchedulerPlannerQueue(
|
||||
@@ -47,10 +47,10 @@ public static class SchedulerQueueServiceCollectionExtensions
|
||||
});
|
||||
|
||||
services.AddSingleton<ISchedulerRunnerQueue>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
return options.Kind switch
|
||||
{
|
||||
SchedulerQueueTransportKind.Redis => new RedisSchedulerRunnerQueue(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Transport backends supported by the scheduler queue abstraction.
|
||||
/// </summary>
|
||||
public enum SchedulerQueueTransportKind
|
||||
{
|
||||
Redis = 0,
|
||||
Nats = 1,
|
||||
}
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Transport backends supported by the scheduler queue abstraction.
|
||||
/// </summary>
|
||||
public enum SchedulerQueueTransportKind
|
||||
{
|
||||
Redis = 0,
|
||||
Nats = 1,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Scheduler.Worker.Events;
|
||||
@@ -14,85 +14,85 @@ using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.DependencyInjection;
|
||||
|
||||
public static class SchedulerWorkerServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSchedulerWorker(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services
|
||||
.AddOptions<SchedulerWorkerOptions>()
|
||||
.Bind(configuration)
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<SchedulerWorkerMetrics>();
|
||||
services.AddSingleton<IImpactTargetingService, ImpactTargetingService>();
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.DependencyInjection;
|
||||
|
||||
public static class SchedulerWorkerServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSchedulerWorker(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services
|
||||
.AddOptions<SchedulerWorkerOptions>()
|
||||
.Bind(configuration)
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<SchedulerWorkerMetrics>();
|
||||
services.AddSingleton<IImpactTargetingService, ImpactTargetingService>();
|
||||
services.AddSingleton<IImpactShardPlanner, ImpactShardPlanner>();
|
||||
services.AddSingleton<IPlannerQueueDispatchService, PlannerQueueDispatchService>();
|
||||
services.AddSingleton<PlannerExecutionService>();
|
||||
services.AddSingleton<IRunnerExecutionService, RunnerExecutionService>();
|
||||
services.AddSingleton<IPolicyRunTargetingService, PolicyRunTargetingService>();
|
||||
services.AddSingleton<PolicyRunExecutionService>();
|
||||
services.AddSingleton<GraphBuildExecutionService>();
|
||||
services.AddSingleton<GraphOverlayExecutionService>();
|
||||
services.AddSingleton<ISchedulerEventPublisher>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var queue = sp.GetService<INotifyEventQueue>();
|
||||
var queueOptions = sp.GetService<NotifyEventQueueOptions>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
if (queue is null || queueOptions is null)
|
||||
{
|
||||
return new NullSchedulerEventPublisher(loggerFactory.CreateLogger<NullSchedulerEventPublisher>());
|
||||
}
|
||||
|
||||
return new SchedulerEventPublisher(
|
||||
queue,
|
||||
queueOptions,
|
||||
timeProvider,
|
||||
loggerFactory.CreateLogger<SchedulerEventPublisher>());
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyRunTargetingService, PolicyRunTargetingService>();
|
||||
services.AddSingleton<PolicyRunExecutionService>();
|
||||
services.AddSingleton<GraphBuildExecutionService>();
|
||||
services.AddSingleton<GraphOverlayExecutionService>();
|
||||
services.AddSingleton<ISchedulerEventPublisher>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var queue = sp.GetService<INotifyEventQueue>();
|
||||
var queueOptions = sp.GetService<NotifyEventQueueOptions>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
if (queue is null || queueOptions is null)
|
||||
{
|
||||
return new NullSchedulerEventPublisher(loggerFactory.CreateLogger<NullSchedulerEventPublisher>());
|
||||
}
|
||||
|
||||
return new SchedulerEventPublisher(
|
||||
queue,
|
||||
queueOptions,
|
||||
timeProvider,
|
||||
loggerFactory.CreateLogger<SchedulerEventPublisher>());
|
||||
});
|
||||
|
||||
services.AddHttpClient<IScannerReportClient, HttpScannerReportClient>();
|
||||
services.AddHttpClient<IPolicyRunClient, HttpPolicyRunClient>();
|
||||
services.AddHttpClient<IPolicySimulationWebhookClient, HttpPolicySimulationWebhookClient>();
|
||||
services.AddHttpClient<ICartographerBuildClient, HttpCartographerBuildClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.Cartographer.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
services.AddHttpClient<ICartographerOverlayClient, HttpCartographerOverlayClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.Cartographer.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
services.AddHttpClient<ICartographerBuildClient, HttpCartographerBuildClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.Cartographer.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
services.AddHttpClient<ICartographerOverlayClient, HttpCartographerOverlayClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.Cartographer.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
services.AddHttpClient<IGraphJobCompletionClient, HttpGraphJobCompletionClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.SchedulerApi.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (options.SchedulerApi.BaseAddress is { } baseAddress)
|
||||
{
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddHostedService<PlannerBackgroundService>();
|
||||
services.AddHostedService<PlannerQueueDispatcherBackgroundService>();
|
||||
services.AddHostedService<RunnerBackgroundService>();
|
||||
|
||||
@@ -1,234 +1,234 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal sealed class HttpCartographerBuildClient : ICartographerBuildClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpCartographerBuildClient> _logger;
|
||||
|
||||
public HttpCartographerBuildClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpCartographerBuildClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
|
||||
if (apiOptions.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cartographer base address must be configured before starting graph builds.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
var payload = new CartographerBuildRequest
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
SbomId = job.SbomId,
|
||||
SbomVersionId = job.SbomVersionId,
|
||||
SbomDigest = job.SbomDigest,
|
||||
GraphSnapshotId = job.GraphSnapshotId,
|
||||
CorrelationId = job.CorrelationId,
|
||||
Metadata = job.Metadata
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.BuildPath)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new CartographerBuildClientException($"Cartographer build submission failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
CartographerBuildResponseModel? model = null;
|
||||
try
|
||||
{
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Cartographer build response for job {JobId}.", job.Id);
|
||||
}
|
||||
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running) && !string.IsNullOrWhiteSpace(model?.CartographerJobId))
|
||||
{
|
||||
return await PollBuildStatusAsync(model.CartographerJobId!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new CartographerBuildResult(
|
||||
status,
|
||||
model?.CartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private static GraphJobStatus ParseStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return GraphJobStatus.Completed;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => GraphJobStatus.Pending,
|
||||
"queued" => GraphJobStatus.Queued,
|
||||
"running" => GraphJobStatus.Running,
|
||||
"failed" => GraphJobStatus.Failed,
|
||||
"cancelled" => GraphJobStatus.Cancelled,
|
||||
_ => GraphJobStatus.Completed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<CartographerBuildResult> PollBuildStatusAsync(string cartographerJobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
if (string.IsNullOrWhiteSpace(apiOptions.StatusPath))
|
||||
{
|
||||
return new CartographerBuildResult(GraphJobStatus.Running, cartographerJobId, null, null, "status path not configured");
|
||||
}
|
||||
|
||||
var statusPath = apiOptions.StatusPath.Replace("{jobId}", Uri.EscapeDataString(cartographerJobId), StringComparison.Ordinal);
|
||||
var attempt = 0;
|
||||
CartographerBuildResponseModel? model = null;
|
||||
|
||||
while (attempt < graphOptions.MaxAttempts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
using var statusResponse = await _httpClient.GetAsync(statusPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!statusResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await statusResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Cartographer build status request failed ({StatusCode}) for job {JobId}: {Body}", (int)statusResponse.StatusCode, cartographerJobId, body);
|
||||
break;
|
||||
}
|
||||
|
||||
model = await statusResponse.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
|
||||
{
|
||||
return new CartographerBuildResult(
|
||||
status,
|
||||
cartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Polling Cartographer build status failed for job {JobId}.", cartographerJobId);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fallbackStatus = ParseStatus(model?.Status);
|
||||
return new CartographerBuildResult(
|
||||
fallbackStatus,
|
||||
cartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private sealed record CartographerBuildRequest
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomVersionId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record CartographerBuildResponseModel
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("cartographerJobId")]
|
||||
public string? CartographerJobId { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CartographerBuildClientException : Exception
|
||||
{
|
||||
public CartographerBuildClientException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal sealed class HttpCartographerBuildClient : ICartographerBuildClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpCartographerBuildClient> _logger;
|
||||
|
||||
public HttpCartographerBuildClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpCartographerBuildClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
|
||||
if (apiOptions.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cartographer base address must be configured before starting graph builds.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
var payload = new CartographerBuildRequest
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
SbomId = job.SbomId,
|
||||
SbomVersionId = job.SbomVersionId,
|
||||
SbomDigest = job.SbomDigest,
|
||||
GraphSnapshotId = job.GraphSnapshotId,
|
||||
CorrelationId = job.CorrelationId,
|
||||
Metadata = job.Metadata
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.BuildPath)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new CartographerBuildClientException($"Cartographer build submission failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
CartographerBuildResponseModel? model = null;
|
||||
try
|
||||
{
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Cartographer build response for job {JobId}.", job.Id);
|
||||
}
|
||||
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running) && !string.IsNullOrWhiteSpace(model?.CartographerJobId))
|
||||
{
|
||||
return await PollBuildStatusAsync(model.CartographerJobId!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new CartographerBuildResult(
|
||||
status,
|
||||
model?.CartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private static GraphJobStatus ParseStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return GraphJobStatus.Completed;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => GraphJobStatus.Pending,
|
||||
"queued" => GraphJobStatus.Queued,
|
||||
"running" => GraphJobStatus.Running,
|
||||
"failed" => GraphJobStatus.Failed,
|
||||
"cancelled" => GraphJobStatus.Cancelled,
|
||||
_ => GraphJobStatus.Completed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<CartographerBuildResult> PollBuildStatusAsync(string cartographerJobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
if (string.IsNullOrWhiteSpace(apiOptions.StatusPath))
|
||||
{
|
||||
return new CartographerBuildResult(GraphJobStatus.Running, cartographerJobId, null, null, "status path not configured");
|
||||
}
|
||||
|
||||
var statusPath = apiOptions.StatusPath.Replace("{jobId}", Uri.EscapeDataString(cartographerJobId), StringComparison.Ordinal);
|
||||
var attempt = 0;
|
||||
CartographerBuildResponseModel? model = null;
|
||||
|
||||
while (attempt < graphOptions.MaxAttempts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
using var statusResponse = await _httpClient.GetAsync(statusPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!statusResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await statusResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Cartographer build status request failed ({StatusCode}) for job {JobId}: {Body}", (int)statusResponse.StatusCode, cartographerJobId, body);
|
||||
break;
|
||||
}
|
||||
|
||||
model = await statusResponse.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
|
||||
{
|
||||
return new CartographerBuildResult(
|
||||
status,
|
||||
cartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Polling Cartographer build status failed for job {JobId}.", cartographerJobId);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fallbackStatus = ParseStatus(model?.Status);
|
||||
return new CartographerBuildResult(
|
||||
fallbackStatus,
|
||||
cartographerJobId,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private sealed record CartographerBuildRequest
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomVersionId { get; init; } = string.Empty;
|
||||
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record CartographerBuildResponseModel
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("cartographerJobId")]
|
||||
public string? CartographerJobId { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CartographerBuildClientException : Exception
|
||||
{
|
||||
public CartographerBuildClientException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal sealed class HttpCartographerOverlayClient : ICartographerOverlayClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpCartographerOverlayClient> _logger;
|
||||
|
||||
public HttpCartographerOverlayClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpCartographerOverlayClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
|
||||
if (apiOptions.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cartographer base address must be configured before starting graph overlays.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
var payload = new CartographerOverlayRequest
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
GraphSnapshotId = job.GraphSnapshotId,
|
||||
OverlayKind = job.OverlayKind.ToString().ToLowerInvariant(),
|
||||
OverlayKey = job.OverlayKey,
|
||||
Subjects = job.Subjects,
|
||||
CorrelationId = job.CorrelationId,
|
||||
Metadata = job.Metadata
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.OverlayPath)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new CartographerOverlayClientException($"Cartographer overlay submission failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
CartographerOverlayResponseModel? model = null;
|
||||
try
|
||||
{
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Cartographer overlay response for job {JobId}.", job.Id);
|
||||
}
|
||||
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running))
|
||||
{
|
||||
return await PollOverlayStatusAsync(job.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new CartographerOverlayResult(
|
||||
status,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private static GraphJobStatus ParseStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return GraphJobStatus.Completed;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => GraphJobStatus.Pending,
|
||||
"queued" => GraphJobStatus.Queued,
|
||||
"running" => GraphJobStatus.Running,
|
||||
"failed" => GraphJobStatus.Failed,
|
||||
"cancelled" => GraphJobStatus.Cancelled,
|
||||
_ => GraphJobStatus.Completed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<CartographerOverlayResult> PollOverlayStatusAsync(string overlayJobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
if (string.IsNullOrWhiteSpace(apiOptions.OverlayStatusPath))
|
||||
{
|
||||
return new CartographerOverlayResult(GraphJobStatus.Running, null, null, "overlay status path not configured");
|
||||
}
|
||||
|
||||
var path = apiOptions.OverlayStatusPath.Replace("{jobId}", Uri.EscapeDataString(overlayJobId), StringComparison.Ordinal);
|
||||
var attempt = 0;
|
||||
CartographerOverlayResponseModel? model = null;
|
||||
|
||||
while (attempt < graphOptions.MaxAttempts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Cartographer overlay status request failed ({StatusCode}) for job {JobId}: {Body}", (int)response.StatusCode, overlayJobId, body);
|
||||
break;
|
||||
}
|
||||
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
|
||||
{
|
||||
return new CartographerOverlayResult(
|
||||
status,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Polling Cartographer overlay status failed for job {JobId}.", overlayJobId);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fallbackStatus = ParseStatus(model?.Status);
|
||||
return new CartographerOverlayResult(
|
||||
fallbackStatus,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private sealed record CartographerOverlayRequest
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public string OverlayKind { get; init; } = string.Empty;
|
||||
|
||||
public string OverlayKey { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Subjects { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record CartographerOverlayResponseModel
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CartographerOverlayClientException : Exception
|
||||
{
|
||||
public CartographerOverlayClientException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal sealed class HttpCartographerOverlayClient : ICartographerOverlayClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpCartographerOverlayClient> _logger;
|
||||
|
||||
public HttpCartographerOverlayClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpCartographerOverlayClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
|
||||
if (apiOptions.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cartographer base address must be configured before starting graph overlays.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
var payload = new CartographerOverlayRequest
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
GraphSnapshotId = job.GraphSnapshotId,
|
||||
OverlayKind = job.OverlayKind.ToString().ToLowerInvariant(),
|
||||
OverlayKey = job.OverlayKey,
|
||||
Subjects = job.Subjects,
|
||||
CorrelationId = job.CorrelationId,
|
||||
Metadata = job.Metadata
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.OverlayPath)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new CartographerOverlayClientException($"Cartographer overlay submission failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
CartographerOverlayResponseModel? model = null;
|
||||
try
|
||||
{
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Cartographer overlay response for job {JobId}.", job.Id);
|
||||
}
|
||||
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running))
|
||||
{
|
||||
return await PollOverlayStatusAsync(job.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new CartographerOverlayResult(
|
||||
status,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private static GraphJobStatus ParseStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return GraphJobStatus.Completed;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => GraphJobStatus.Pending,
|
||||
"queued" => GraphJobStatus.Queued,
|
||||
"running" => GraphJobStatus.Running,
|
||||
"failed" => GraphJobStatus.Failed,
|
||||
"cancelled" => GraphJobStatus.Cancelled,
|
||||
_ => GraphJobStatus.Completed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<CartographerOverlayResult> PollOverlayStatusAsync(string overlayJobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var apiOptions = graphOptions.Cartographer;
|
||||
if (string.IsNullOrWhiteSpace(apiOptions.OverlayStatusPath))
|
||||
{
|
||||
return new CartographerOverlayResult(GraphJobStatus.Running, null, null, "overlay status path not configured");
|
||||
}
|
||||
|
||||
var path = apiOptions.OverlayStatusPath.Replace("{jobId}", Uri.EscapeDataString(overlayJobId), StringComparison.Ordinal);
|
||||
var attempt = 0;
|
||||
CartographerOverlayResponseModel? model = null;
|
||||
|
||||
while (attempt < graphOptions.MaxAttempts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Cartographer overlay status request failed ({StatusCode}) for job {JobId}: {Body}", (int)response.StatusCode, overlayJobId, body);
|
||||
break;
|
||||
}
|
||||
|
||||
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
var status = ParseStatus(model?.Status);
|
||||
|
||||
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
|
||||
{
|
||||
return new CartographerOverlayResult(
|
||||
status,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Polling Cartographer overlay status failed for job {JobId}.", overlayJobId);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fallbackStatus = ParseStatus(model?.Status);
|
||||
return new CartographerOverlayResult(
|
||||
fallbackStatus,
|
||||
model?.GraphSnapshotId,
|
||||
model?.ResultUri,
|
||||
model?.Error);
|
||||
}
|
||||
|
||||
private sealed record CartographerOverlayRequest
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public string OverlayKind { get; init; } = string.Empty;
|
||||
|
||||
public string OverlayKey { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Subjects { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record CartographerOverlayResponseModel
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CartographerOverlayClientException : Exception
|
||||
{
|
||||
public CartographerOverlayClientException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal interface ICartographerBuildClient
|
||||
{
|
||||
Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record CartographerBuildResult(
|
||||
GraphJobStatus Status,
|
||||
string? CartographerJobId,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? Error);
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal interface ICartographerBuildClient
|
||||
{
|
||||
Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record CartographerBuildResult(
|
||||
GraphJobStatus Status,
|
||||
string? CartographerJobId,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? Error);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal interface ICartographerOverlayClient
|
||||
{
|
||||
Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record CartographerOverlayResult(
|
||||
GraphJobStatus Status,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? Error);
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
|
||||
internal interface ICartographerOverlayClient
|
||||
{
|
||||
Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record CartographerOverlayResult(
|
||||
GraphJobStatus Status,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? Error);
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
|
||||
internal sealed class HttpGraphJobCompletionClient : IGraphJobCompletionClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpGraphJobCompletionClient> _logger;
|
||||
|
||||
public HttpGraphJobCompletionClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpGraphJobCompletionClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var api = graphOptions.SchedulerApi;
|
||||
|
||||
if (api.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler API base address must be configured before notifying graph job completion.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != api.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = api.BaseAddress;
|
||||
}
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, api.CompletionPath)
|
||||
{
|
||||
Content = JsonContent.Create(new SchedulerCompletionRequest(request), options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(api.ApiKeyHeader) && !string.IsNullOrWhiteSpace(api.ApiKey))
|
||||
{
|
||||
message.Headers.TryAddWithoutValidation(api.ApiKeyHeader!, api.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Scheduler API returned status {StatusCode} while completing graph job {JobId}: {Body}",
|
||||
(int)response.StatusCode,
|
||||
request.JobId,
|
||||
body);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SchedulerCompletionRequest(
|
||||
string JobId,
|
||||
string JobType,
|
||||
StellaOps.Scheduler.Models.GraphJobStatus Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? CorrelationId,
|
||||
string? Error)
|
||||
{
|
||||
public SchedulerCompletionRequest(GraphJobCompletionRequestDto dto)
|
||||
: this(
|
||||
dto.JobId,
|
||||
dto.JobType,
|
||||
dto.Status,
|
||||
dto.OccurredAt,
|
||||
dto.GraphSnapshotId,
|
||||
dto.ResultUri,
|
||||
dto.CorrelationId,
|
||||
dto.Error)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
|
||||
internal sealed class HttpGraphJobCompletionClient : IGraphJobCompletionClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpGraphJobCompletionClient> _logger;
|
||||
|
||||
public HttpGraphJobCompletionClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpGraphJobCompletionClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var graphOptions = _options.Value.Graph;
|
||||
var api = graphOptions.SchedulerApi;
|
||||
|
||||
if (api.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler API base address must be configured before notifying graph job completion.");
|
||||
}
|
||||
|
||||
if (_httpClient.BaseAddress != api.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = api.BaseAddress;
|
||||
}
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, api.CompletionPath)
|
||||
{
|
||||
Content = JsonContent.Create(new SchedulerCompletionRequest(request), options: SerializerOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(api.ApiKeyHeader) && !string.IsNullOrWhiteSpace(api.ApiKey))
|
||||
{
|
||||
message.Headers.TryAddWithoutValidation(api.ApiKeyHeader!, api.ApiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Scheduler API returned status {StatusCode} while completing graph job {JobId}: {Body}",
|
||||
(int)response.StatusCode,
|
||||
request.JobId,
|
||||
body);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SchedulerCompletionRequest(
|
||||
string JobId,
|
||||
string JobType,
|
||||
StellaOps.Scheduler.Models.GraphJobStatus Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? CorrelationId,
|
||||
string? Error)
|
||||
{
|
||||
public SchedulerCompletionRequest(GraphJobCompletionRequestDto dto)
|
||||
: this(
|
||||
dto.JobId,
|
||||
dto.JobType,
|
||||
dto.Status,
|
||||
dto.OccurredAt,
|
||||
dto.GraphSnapshotId,
|
||||
dto.ResultUri,
|
||||
dto.CorrelationId,
|
||||
dto.Error)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
|
||||
internal interface IGraphJobCompletionClient
|
||||
{
|
||||
Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record GraphJobCompletionRequestDto(
|
||||
string JobId,
|
||||
string JobType,
|
||||
StellaOps.Scheduler.Models.GraphJobStatus Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? CorrelationId,
|
||||
string? Error);
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
|
||||
internal interface IGraphJobCompletionClient
|
||||
{
|
||||
Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record GraphJobCompletionRequestDto(
|
||||
string JobId,
|
||||
string JobType,
|
||||
StellaOps.Scheduler.Models.GraphJobStatus Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? GraphSnapshotId,
|
||||
string? ResultUri,
|
||||
string? CorrelationId,
|
||||
string? Error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +1,154 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal sealed class HttpPolicyRunClient : IPolicyRunClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpPolicyRunClient> _logger;
|
||||
|
||||
public HttpPolicyRunClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpPolicyRunClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var apiOptions = _options.Value.Policy.Api;
|
||||
ConfigureHttpClient(apiOptions);
|
||||
|
||||
var path = ResolvePath(apiOptions, job.PolicyId, request.Mode);
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = JsonContent.Create(request, options: SerializerOptions)
|
||||
};
|
||||
|
||||
AddHeaders(message, apiOptions, job, request);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(apiOptions.RequestTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorPayload = await SafeReadAsync(response, timeoutCts.Token).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Policy run submission for policy {PolicyId} failed with status {Status}.",
|
||||
job.PolicyId,
|
||||
(int)response.StatusCode);
|
||||
return PolicyRunSubmissionResult.Failed(errorPayload);
|
||||
}
|
||||
|
||||
if (request.Mode == PolicyRunMode.Simulate)
|
||||
{
|
||||
// Response body contains diff summary; callers handle separately if needed.
|
||||
return PolicyRunSubmissionResult.Succeeded(job.RunId ?? request.RunId, request.QueuedAt);
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<PolicyRunSubmitResponse>(SerializerOptions, timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var runId = payload?.RunId ?? request.RunId ?? job.RunId;
|
||||
var queuedAt = payload?.QueuedAt ?? request.QueuedAt;
|
||||
return PolicyRunSubmissionResult.Succeeded(runId, queuedAt);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy run submission for policy {PolicyId} timed out after {Timeout}.",
|
||||
job.PolicyId,
|
||||
apiOptions.RequestTimeout);
|
||||
return PolicyRunSubmissionResult.Failed("Request timed out.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Policy run submission for policy {PolicyId} failed with exception.", job.PolicyId);
|
||||
return PolicyRunSubmissionResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureHttpClient(SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions)
|
||||
{
|
||||
if (apiOptions.BaseAddress is not null && _httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
_httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
|
||||
private static string ResolvePath(SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions, string policyId, PolicyRunMode mode)
|
||||
{
|
||||
var placeholder = "{policyId}";
|
||||
var template = mode == PolicyRunMode.Simulate ? apiOptions.SimulatePath : apiOptions.RunsPath;
|
||||
if (!template.Contains(placeholder, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Policy API path '{template}' does not contain required placeholder '{placeholder}'.");
|
||||
}
|
||||
|
||||
return template.Replace(placeholder, Uri.EscapeDataString(policyId), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddHeaders(
|
||||
HttpRequestMessage message,
|
||||
SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions,
|
||||
PolicyRunJob job,
|
||||
PolicyRunRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.TenantHeader))
|
||||
{
|
||||
message.Headers.TryAddWithoutValidation(apiOptions.TenantHeader, job.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.IdempotencyHeader))
|
||||
{
|
||||
var key = request.RunId ?? job.RunId ?? job.Id;
|
||||
message.Headers.TryAddWithoutValidation(apiOptions.IdempotencyHeader, key);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> SafeReadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PolicyRunSubmitResponse(
|
||||
string? RunId,
|
||||
DateTimeOffset? QueuedAt,
|
||||
string? Status);
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal sealed class HttpPolicyRunClient : IPolicyRunClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly ILogger<HttpPolicyRunClient> _logger;
|
||||
|
||||
public HttpPolicyRunClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
ILogger<HttpPolicyRunClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var apiOptions = _options.Value.Policy.Api;
|
||||
ConfigureHttpClient(apiOptions);
|
||||
|
||||
var path = ResolvePath(apiOptions, job.PolicyId, request.Mode);
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = JsonContent.Create(request, options: SerializerOptions)
|
||||
};
|
||||
|
||||
AddHeaders(message, apiOptions, job, request);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(apiOptions.RequestTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorPayload = await SafeReadAsync(response, timeoutCts.Token).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Policy run submission for policy {PolicyId} failed with status {Status}.",
|
||||
job.PolicyId,
|
||||
(int)response.StatusCode);
|
||||
return PolicyRunSubmissionResult.Failed(errorPayload);
|
||||
}
|
||||
|
||||
if (request.Mode == PolicyRunMode.Simulate)
|
||||
{
|
||||
// Response body contains diff summary; callers handle separately if needed.
|
||||
return PolicyRunSubmissionResult.Succeeded(job.RunId ?? request.RunId, request.QueuedAt);
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<PolicyRunSubmitResponse>(SerializerOptions, timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var runId = payload?.RunId ?? request.RunId ?? job.RunId;
|
||||
var queuedAt = payload?.QueuedAt ?? request.QueuedAt;
|
||||
return PolicyRunSubmissionResult.Succeeded(runId, queuedAt);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy run submission for policy {PolicyId} timed out after {Timeout}.",
|
||||
job.PolicyId,
|
||||
apiOptions.RequestTimeout);
|
||||
return PolicyRunSubmissionResult.Failed("Request timed out.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Policy run submission for policy {PolicyId} failed with exception.", job.PolicyId);
|
||||
return PolicyRunSubmissionResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureHttpClient(SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions)
|
||||
{
|
||||
if (apiOptions.BaseAddress is not null && _httpClient.BaseAddress != apiOptions.BaseAddress)
|
||||
{
|
||||
_httpClient.BaseAddress = apiOptions.BaseAddress;
|
||||
}
|
||||
|
||||
_httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
|
||||
private static string ResolvePath(SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions, string policyId, PolicyRunMode mode)
|
||||
{
|
||||
var placeholder = "{policyId}";
|
||||
var template = mode == PolicyRunMode.Simulate ? apiOptions.SimulatePath : apiOptions.RunsPath;
|
||||
if (!template.Contains(placeholder, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Policy API path '{template}' does not contain required placeholder '{placeholder}'.");
|
||||
}
|
||||
|
||||
return template.Replace(placeholder, Uri.EscapeDataString(policyId), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddHeaders(
|
||||
HttpRequestMessage message,
|
||||
SchedulerWorkerOptions.PolicyOptions.ApiOptions apiOptions,
|
||||
PolicyRunJob job,
|
||||
PolicyRunRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.TenantHeader))
|
||||
{
|
||||
message.Headers.TryAddWithoutValidation(apiOptions.TenantHeader, job.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiOptions.IdempotencyHeader))
|
||||
{
|
||||
var key = request.RunId ?? job.RunId ?? job.Id;
|
||||
message.Headers.TryAddWithoutValidation(apiOptions.IdempotencyHeader, key);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> SafeReadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PolicyRunSubmitResponse(
|
||||
string? RunId,
|
||||
DateTimeOffset? QueuedAt,
|
||||
string? Status);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal interface IPolicyRunClient
|
||||
{
|
||||
Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal interface IPolicyRunClient
|
||||
{
|
||||
Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal interface IPolicyRunTargetingService
|
||||
{
|
||||
Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal interface IPolicyRunTargetingService
|
||||
{
|
||||
Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
internal enum PolicyRunExecutionResultType
|
||||
{
|
||||
Submitted,
|
||||
Retrying,
|
||||
Failed,
|
||||
Cancelled,
|
||||
NoOp
|
||||
}
|
||||
|
||||
internal readonly record struct PolicyRunExecutionResult(
|
||||
PolicyRunExecutionResultType Type,
|
||||
PolicyRunJob UpdatedJob,
|
||||
string? Error)
|
||||
{
|
||||
public static PolicyRunExecutionResult Submitted(PolicyRunJob job)
|
||||
=> new(PolicyRunExecutionResultType.Submitted, job, null);
|
||||
|
||||
public static PolicyRunExecutionResult Retrying(PolicyRunJob job, string? error)
|
||||
=> new(PolicyRunExecutionResultType.Retrying, job, error);
|
||||
|
||||
public static PolicyRunExecutionResult Failed(PolicyRunJob job, string? error)
|
||||
=> new(PolicyRunExecutionResultType.Failed, job, error);
|
||||
|
||||
public static PolicyRunExecutionResult Cancelled(PolicyRunJob job)
|
||||
=> new(PolicyRunExecutionResultType.Cancelled, job, null);
|
||||
|
||||
public static PolicyRunExecutionResult NoOp(PolicyRunJob job, string? reason = null)
|
||||
=> new(PolicyRunExecutionResultType.NoOp, job, reason);
|
||||
}
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
internal enum PolicyRunExecutionResultType
|
||||
{
|
||||
Submitted,
|
||||
Retrying,
|
||||
Failed,
|
||||
Cancelled,
|
||||
NoOp
|
||||
}
|
||||
|
||||
internal readonly record struct PolicyRunExecutionResult(
|
||||
PolicyRunExecutionResultType Type,
|
||||
PolicyRunJob UpdatedJob,
|
||||
string? Error)
|
||||
{
|
||||
public static PolicyRunExecutionResult Submitted(PolicyRunJob job)
|
||||
=> new(PolicyRunExecutionResultType.Submitted, job, null);
|
||||
|
||||
public static PolicyRunExecutionResult Retrying(PolicyRunJob job, string? error)
|
||||
=> new(PolicyRunExecutionResultType.Retrying, job, error);
|
||||
|
||||
public static PolicyRunExecutionResult Failed(PolicyRunJob job, string? error)
|
||||
=> new(PolicyRunExecutionResultType.Failed, job, error);
|
||||
|
||||
public static PolicyRunExecutionResult Cancelled(PolicyRunJob job)
|
||||
=> new(PolicyRunExecutionResultType.Cancelled, job, null);
|
||||
|
||||
public static PolicyRunExecutionResult NoOp(PolicyRunJob job, string? reason = null)
|
||||
=> new(PolicyRunExecutionResultType.NoOp, job, reason);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal readonly record struct PolicyRunSubmissionResult
|
||||
{
|
||||
private PolicyRunSubmissionResult(bool success, string? runId, DateTimeOffset? queuedAt, string? error)
|
||||
{
|
||||
Success = success;
|
||||
RunId = runId;
|
||||
QueuedAt = queuedAt;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? RunId { get; }
|
||||
|
||||
public DateTimeOffset? QueuedAt { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public static PolicyRunSubmissionResult Succeeded(string? runId, DateTimeOffset? queuedAt)
|
||||
=> new(success: true, runId, queuedAt, error: null);
|
||||
|
||||
public static PolicyRunSubmissionResult Failed(string? error)
|
||||
=> new(success: false, runId: null, queuedAt: null, error);
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal readonly record struct PolicyRunSubmissionResult
|
||||
{
|
||||
private PolicyRunSubmissionResult(bool success, string? runId, DateTimeOffset? queuedAt, string? error)
|
||||
{
|
||||
Success = success;
|
||||
RunId = runId;
|
||||
QueuedAt = queuedAt;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? RunId { get; }
|
||||
|
||||
public DateTimeOffset? QueuedAt { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public static PolicyRunSubmissionResult Succeeded(string? runId, DateTimeOffset? queuedAt)
|
||||
=> new(success: true, runId, queuedAt, error: null);
|
||||
|
||||
public static PolicyRunSubmissionResult Failed(string? error)
|
||||
=> new(success: false, runId: null, queuedAt: null, error);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal enum PolicyRunTargetingStatus
|
||||
{
|
||||
Unchanged,
|
||||
Targeted,
|
||||
NoWork
|
||||
}
|
||||
|
||||
internal readonly record struct PolicyRunTargetingResult(
|
||||
PolicyRunTargetingStatus Status,
|
||||
PolicyRunJob Job,
|
||||
string? Reason)
|
||||
{
|
||||
public static PolicyRunTargetingResult Unchanged(PolicyRunJob job)
|
||||
=> new(PolicyRunTargetingStatus.Unchanged, job, null);
|
||||
|
||||
public static PolicyRunTargetingResult Targeted(PolicyRunJob job)
|
||||
=> new(PolicyRunTargetingStatus.Targeted, job, null);
|
||||
|
||||
public static PolicyRunTargetingResult NoWork(PolicyRunJob job, string? reason)
|
||||
=> new(PolicyRunTargetingStatus.NoWork, job, reason);
|
||||
}
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal enum PolicyRunTargetingStatus
|
||||
{
|
||||
Unchanged,
|
||||
Targeted,
|
||||
NoWork
|
||||
}
|
||||
|
||||
internal readonly record struct PolicyRunTargetingResult(
|
||||
PolicyRunTargetingStatus Status,
|
||||
PolicyRunJob Job,
|
||||
string? Reason)
|
||||
{
|
||||
public static PolicyRunTargetingResult Unchanged(PolicyRunJob job)
|
||||
=> new(PolicyRunTargetingStatus.Unchanged, job, null);
|
||||
|
||||
public static PolicyRunTargetingResult Targeted(PolicyRunJob job)
|
||||
=> new(PolicyRunTargetingStatus.Targeted, job, null);
|
||||
|
||||
public static PolicyRunTargetingResult NoWork(PolicyRunJob job, string? reason)
|
||||
=> new(PolicyRunTargetingStatus.NoWork, job, reason);
|
||||
}
|
||||
|
||||
@@ -1,455 +1,455 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal sealed class PolicyRunTargetingService : IPolicyRunTargetingService
|
||||
{
|
||||
private static readonly string[] DirectSbomMetadataKeys =
|
||||
{
|
||||
"delta.sboms",
|
||||
"delta.sbomset",
|
||||
"delta:sboms",
|
||||
"delta_sbomset"
|
||||
};
|
||||
|
||||
private static readonly string[] ProductKeyMetadataKeys =
|
||||
{
|
||||
"delta.purls",
|
||||
"delta.productkeys",
|
||||
"delta.components",
|
||||
"delta:product_keys"
|
||||
};
|
||||
|
||||
private static readonly string[] VulnerabilityMetadataKeys =
|
||||
{
|
||||
"delta.vulns",
|
||||
"delta.vulnerabilities",
|
||||
"delta.cves",
|
||||
"delta:vulnerability_ids"
|
||||
};
|
||||
|
||||
private readonly IImpactTargetingService _impactTargetingService;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyRunTargetingService> _logger;
|
||||
|
||||
public PolicyRunTargetingService(
|
||||
IImpactTargetingService impactTargetingService,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicyRunTargetingService> logger)
|
||||
{
|
||||
_impactTargetingService = impactTargetingService ?? throw new ArgumentNullException(nameof(impactTargetingService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var policyOptions = _options.Value.Policy;
|
||||
var targetingOptions = policyOptions.Targeting;
|
||||
|
||||
if (!targetingOptions.Enabled)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
if (job.Mode != PolicyRunMode.Incremental)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var inputs = job.Inputs ?? PolicyRunInputs.Empty;
|
||||
if (!inputs.SbomSet.IsDefaultOrEmpty && inputs.SbomSet.Length > 0)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var metadata = job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
var directSboms = ParseList(metadata, DirectSbomMetadataKeys);
|
||||
var productKeys = ParseList(metadata, ProductKeyMetadataKeys);
|
||||
var vulnerabilityIds = ParseList(metadata, VulnerabilityMetadataKeys);
|
||||
|
||||
if (directSboms.Count == 0 && productKeys.Count == 0 && vulnerabilityIds.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Policy run job {JobId} has no delta metadata; skipping targeting.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddIdentifiers(candidates, directSboms);
|
||||
|
||||
var selector = BuildSelector(job, metadata);
|
||||
var usageOnly = DetermineUsageOnly(metadata, targetingOptions.DefaultUsageOnly);
|
||||
|
||||
if (productKeys.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var impactSet = await _impactTargetingService
|
||||
.ResolveByPurlsAsync(productKeys, usageOnly, selector, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddFromImpactSet(candidates, impactSet);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Policy run job {JobId} failed resolving delta by product keys; falling back to full run.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
}
|
||||
|
||||
if (vulnerabilityIds.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var impactSet = await _impactTargetingService
|
||||
.ResolveByVulnerabilitiesAsync(vulnerabilityIds, usageOnly, selector, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddFromImpactSet(candidates, impactSet);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Policy run job {JobId} failed resolving delta by vulnerability ids; falling back to full run.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} produced no SBOM targets (policy={PolicyId}).",
|
||||
job.Id,
|
||||
job.PolicyId);
|
||||
return PolicyRunTargetingResult.NoWork(job, "no_matches");
|
||||
}
|
||||
|
||||
if (candidates.Count > targetingOptions.MaxSboms)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy run job {JobId} resolved {Count} SBOMs exceeding limit {Limit}; falling back to full run.",
|
||||
job.Id,
|
||||
candidates.Count,
|
||||
targetingOptions.MaxSboms);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var normalized = candidates
|
||||
.Select(NormalizeSbomId)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} resulted in empty SBOM set after normalization; marking as no-work.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.NoWork(job, "normalized_empty");
|
||||
}
|
||||
|
||||
var updatedInputs = inputs with { SbomSet = normalized };
|
||||
var updatedJob = job with
|
||||
{
|
||||
Inputs = updatedInputs,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} targeted {Count} SBOMs for policy {PolicyId}.",
|
||||
job.Id,
|
||||
normalized.Length,
|
||||
job.PolicyId);
|
||||
|
||||
return PolicyRunTargetingResult.Targeted(updatedJob);
|
||||
}
|
||||
|
||||
private static void AddIdentifiers(HashSet<string> destination, IReadOnlyList<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
destination.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFromImpactSet(HashSet<string> destination, ImpactSet impactSet)
|
||||
{
|
||||
foreach (var image in impactSet.Images)
|
||||
{
|
||||
var sbomId = ExtractSbomId(image);
|
||||
if (!string.IsNullOrEmpty(sbomId))
|
||||
{
|
||||
destination.Add(sbomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractSbomId(ImpactImage image)
|
||||
{
|
||||
if (TryGetLabel(image.Labels, "sbom", out var value) ||
|
||||
TryGetLabel(image.Labels, "sbomid", out value) ||
|
||||
TryGetLabel(image.Labels, "sbom_id", out value) ||
|
||||
TryGetLabel(image.Labels, "sbomId", out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(image.ImageDigest)
|
||||
? null
|
||||
: $"sbom:{image.ImageDigest}";
|
||||
}
|
||||
|
||||
private static bool TryGetLabel(ImmutableSortedDictionary<string, string> labels, string key, out string? value)
|
||||
{
|
||||
foreach (var pair in labels)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeSbomId(string candidate)
|
||||
{
|
||||
var trimmed = candidate.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sbom:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return $"sbom:{trimmed}";
|
||||
}
|
||||
|
||||
private static Selector BuildSelector(PolicyRunJob job, ImmutableSortedDictionary<string, string> metadata)
|
||||
{
|
||||
var scope = SelectorScope.AllImages;
|
||||
if (TryGetMetadataValue(metadata, "policy.selector.scope", out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"namespace" or "bynamespace" => SelectorScope.ByNamespace,
|
||||
"repository" or "byrepository" => SelectorScope.ByRepository,
|
||||
"digest" or "bydigest" => SelectorScope.ByDigest,
|
||||
"labels" or "bylabels" => SelectorScope.ByLabels,
|
||||
_ => SelectorScope.AllImages
|
||||
};
|
||||
}
|
||||
|
||||
var namespaces = scope == SelectorScope.ByNamespace
|
||||
? ParseList(metadata, "policy.selector.namespaces")
|
||||
: Array.Empty<string>();
|
||||
var repositories = scope == SelectorScope.ByRepository
|
||||
? ParseList(metadata, "policy.selector.repositories")
|
||||
: Array.Empty<string>();
|
||||
var digests = scope == SelectorScope.ByDigest
|
||||
? ParseList(metadata, "policy.selector.digests")
|
||||
: Array.Empty<string>();
|
||||
var includeTags = ParseList(metadata, "policy.selector.includeTags", "policy.selector.tags");
|
||||
var labelSelectors = scope == SelectorScope.ByLabels
|
||||
? ParseLabelSelectors(metadata)
|
||||
: ImmutableArray<LabelSelector>.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
return new Selector(
|
||||
scope,
|
||||
job.TenantId,
|
||||
namespaces,
|
||||
repositories,
|
||||
digests,
|
||||
includeTags,
|
||||
labelSelectors,
|
||||
resolvesTags: TryGetMetadataValue(metadata, "policy.selector.resolvesTags", out var resolvesTagsValue) && ParseBoolean(resolvesTagsValue));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new Selector(SelectorScope.AllImages, tenantId: job.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<LabelSelector> ParseLabelSelectors(ImmutableSortedDictionary<string, string> metadata)
|
||||
{
|
||||
if (!TryGetMetadataValue(metadata, "policy.selector.labels", out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
var segments = Split(raw);
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
var selectors = new List<LabelSelector>(segments.Count);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var index = segment.IndexOf('=');
|
||||
if (index <= 0 || index == segment.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = segment[..index].Trim();
|
||||
var values = segment[(index + 1)..].Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
selectors.Add(new LabelSelector(key, values));
|
||||
}
|
||||
|
||||
return selectors.Count == 0
|
||||
? ImmutableArray<LabelSelector>.Empty
|
||||
: selectors.OrderBy(static selector => selector.Key, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(ImmutableSortedDictionary<string, string> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (TryGetMetadataValue(metadata, key, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ParseList(raw);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(trimmed);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return document.RootElement
|
||||
.EnumerateArray()
|
||||
.Where(static element => element.ValueKind == JsonValueKind.String)
|
||||
.Select(static element => element.GetString() ?? string.Empty)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return Split(trimmed);
|
||||
}
|
||||
|
||||
private static List<string> Split(string value)
|
||||
{
|
||||
return value
|
||||
.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static item => item.Trim())
|
||||
.Where(static item => item.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool DetermineUsageOnly(ImmutableSortedDictionary<string, string> metadata, bool defaultValue)
|
||||
{
|
||||
if (TryGetMetadataValue(metadata, "policy.selector.usageOnly", out var raw))
|
||||
{
|
||||
return ParseBoolean(raw);
|
||||
}
|
||||
|
||||
if (TryGetMetadataValue(metadata, "delta.usageOnly", out raw))
|
||||
{
|
||||
return ParseBoolean(raw);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string value)
|
||||
{
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
return numeric != 0;
|
||||
}
|
||||
|
||||
return value.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("y", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryGetMetadataValue(
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string key,
|
||||
out string value)
|
||||
{
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Policy;
|
||||
|
||||
internal sealed class PolicyRunTargetingService : IPolicyRunTargetingService
|
||||
{
|
||||
private static readonly string[] DirectSbomMetadataKeys =
|
||||
{
|
||||
"delta.sboms",
|
||||
"delta.sbomset",
|
||||
"delta:sboms",
|
||||
"delta_sbomset"
|
||||
};
|
||||
|
||||
private static readonly string[] ProductKeyMetadataKeys =
|
||||
{
|
||||
"delta.purls",
|
||||
"delta.productkeys",
|
||||
"delta.components",
|
||||
"delta:product_keys"
|
||||
};
|
||||
|
||||
private static readonly string[] VulnerabilityMetadataKeys =
|
||||
{
|
||||
"delta.vulns",
|
||||
"delta.vulnerabilities",
|
||||
"delta.cves",
|
||||
"delta:vulnerability_ids"
|
||||
};
|
||||
|
||||
private readonly IImpactTargetingService _impactTargetingService;
|
||||
private readonly IOptions<SchedulerWorkerOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyRunTargetingService> _logger;
|
||||
|
||||
public PolicyRunTargetingService(
|
||||
IImpactTargetingService impactTargetingService,
|
||||
IOptions<SchedulerWorkerOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicyRunTargetingService> logger)
|
||||
{
|
||||
_impactTargetingService = impactTargetingService ?? throw new ArgumentNullException(nameof(impactTargetingService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var policyOptions = _options.Value.Policy;
|
||||
var targetingOptions = policyOptions.Targeting;
|
||||
|
||||
if (!targetingOptions.Enabled)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
if (job.Mode != PolicyRunMode.Incremental)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var inputs = job.Inputs ?? PolicyRunInputs.Empty;
|
||||
if (!inputs.SbomSet.IsDefaultOrEmpty && inputs.SbomSet.Length > 0)
|
||||
{
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var metadata = job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
var directSboms = ParseList(metadata, DirectSbomMetadataKeys);
|
||||
var productKeys = ParseList(metadata, ProductKeyMetadataKeys);
|
||||
var vulnerabilityIds = ParseList(metadata, VulnerabilityMetadataKeys);
|
||||
|
||||
if (directSboms.Count == 0 && productKeys.Count == 0 && vulnerabilityIds.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Policy run job {JobId} has no delta metadata; skipping targeting.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddIdentifiers(candidates, directSboms);
|
||||
|
||||
var selector = BuildSelector(job, metadata);
|
||||
var usageOnly = DetermineUsageOnly(metadata, targetingOptions.DefaultUsageOnly);
|
||||
|
||||
if (productKeys.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var impactSet = await _impactTargetingService
|
||||
.ResolveByPurlsAsync(productKeys, usageOnly, selector, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddFromImpactSet(candidates, impactSet);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Policy run job {JobId} failed resolving delta by product keys; falling back to full run.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
}
|
||||
|
||||
if (vulnerabilityIds.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var impactSet = await _impactTargetingService
|
||||
.ResolveByVulnerabilitiesAsync(vulnerabilityIds, usageOnly, selector, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddFromImpactSet(candidates, impactSet);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Policy run job {JobId} failed resolving delta by vulnerability ids; falling back to full run.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} produced no SBOM targets (policy={PolicyId}).",
|
||||
job.Id,
|
||||
job.PolicyId);
|
||||
return PolicyRunTargetingResult.NoWork(job, "no_matches");
|
||||
}
|
||||
|
||||
if (candidates.Count > targetingOptions.MaxSboms)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy run job {JobId} resolved {Count} SBOMs exceeding limit {Limit}; falling back to full run.",
|
||||
job.Id,
|
||||
candidates.Count,
|
||||
targetingOptions.MaxSboms);
|
||||
return PolicyRunTargetingResult.Unchanged(job);
|
||||
}
|
||||
|
||||
var normalized = candidates
|
||||
.Select(NormalizeSbomId)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} resulted in empty SBOM set after normalization; marking as no-work.",
|
||||
job.Id);
|
||||
return PolicyRunTargetingResult.NoWork(job, "normalized_empty");
|
||||
}
|
||||
|
||||
var updatedInputs = inputs with { SbomSet = normalized };
|
||||
var updatedJob = job with
|
||||
{
|
||||
Inputs = updatedInputs,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy run job {JobId} targeted {Count} SBOMs for policy {PolicyId}.",
|
||||
job.Id,
|
||||
normalized.Length,
|
||||
job.PolicyId);
|
||||
|
||||
return PolicyRunTargetingResult.Targeted(updatedJob);
|
||||
}
|
||||
|
||||
private static void AddIdentifiers(HashSet<string> destination, IReadOnlyList<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
destination.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFromImpactSet(HashSet<string> destination, ImpactSet impactSet)
|
||||
{
|
||||
foreach (var image in impactSet.Images)
|
||||
{
|
||||
var sbomId = ExtractSbomId(image);
|
||||
if (!string.IsNullOrEmpty(sbomId))
|
||||
{
|
||||
destination.Add(sbomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractSbomId(ImpactImage image)
|
||||
{
|
||||
if (TryGetLabel(image.Labels, "sbom", out var value) ||
|
||||
TryGetLabel(image.Labels, "sbomid", out value) ||
|
||||
TryGetLabel(image.Labels, "sbom_id", out value) ||
|
||||
TryGetLabel(image.Labels, "sbomId", out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(image.ImageDigest)
|
||||
? null
|
||||
: $"sbom:{image.ImageDigest}";
|
||||
}
|
||||
|
||||
private static bool TryGetLabel(ImmutableSortedDictionary<string, string> labels, string key, out string? value)
|
||||
{
|
||||
foreach (var pair in labels)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeSbomId(string candidate)
|
||||
{
|
||||
var trimmed = candidate.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("sbom:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return $"sbom:{trimmed}";
|
||||
}
|
||||
|
||||
private static Selector BuildSelector(PolicyRunJob job, ImmutableSortedDictionary<string, string> metadata)
|
||||
{
|
||||
var scope = SelectorScope.AllImages;
|
||||
if (TryGetMetadataValue(metadata, "policy.selector.scope", out var scopeValue))
|
||||
{
|
||||
scope = scopeValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"namespace" or "bynamespace" => SelectorScope.ByNamespace,
|
||||
"repository" or "byrepository" => SelectorScope.ByRepository,
|
||||
"digest" or "bydigest" => SelectorScope.ByDigest,
|
||||
"labels" or "bylabels" => SelectorScope.ByLabels,
|
||||
_ => SelectorScope.AllImages
|
||||
};
|
||||
}
|
||||
|
||||
var namespaces = scope == SelectorScope.ByNamespace
|
||||
? ParseList(metadata, "policy.selector.namespaces")
|
||||
: Array.Empty<string>();
|
||||
var repositories = scope == SelectorScope.ByRepository
|
||||
? ParseList(metadata, "policy.selector.repositories")
|
||||
: Array.Empty<string>();
|
||||
var digests = scope == SelectorScope.ByDigest
|
||||
? ParseList(metadata, "policy.selector.digests")
|
||||
: Array.Empty<string>();
|
||||
var includeTags = ParseList(metadata, "policy.selector.includeTags", "policy.selector.tags");
|
||||
var labelSelectors = scope == SelectorScope.ByLabels
|
||||
? ParseLabelSelectors(metadata)
|
||||
: ImmutableArray<LabelSelector>.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
return new Selector(
|
||||
scope,
|
||||
job.TenantId,
|
||||
namespaces,
|
||||
repositories,
|
||||
digests,
|
||||
includeTags,
|
||||
labelSelectors,
|
||||
resolvesTags: TryGetMetadataValue(metadata, "policy.selector.resolvesTags", out var resolvesTagsValue) && ParseBoolean(resolvesTagsValue));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new Selector(SelectorScope.AllImages, tenantId: job.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<LabelSelector> ParseLabelSelectors(ImmutableSortedDictionary<string, string> metadata)
|
||||
{
|
||||
if (!TryGetMetadataValue(metadata, "policy.selector.labels", out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
var segments = Split(raw);
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return ImmutableArray<LabelSelector>.Empty;
|
||||
}
|
||||
|
||||
var selectors = new List<LabelSelector>(segments.Count);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var index = segment.IndexOf('=');
|
||||
if (index <= 0 || index == segment.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = segment[..index].Trim();
|
||||
var values = segment[(index + 1)..].Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
selectors.Add(new LabelSelector(key, values));
|
||||
}
|
||||
|
||||
return selectors.Count == 0
|
||||
? ImmutableArray<LabelSelector>.Empty
|
||||
: selectors.OrderBy(static selector => selector.Key, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(ImmutableSortedDictionary<string, string> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (TryGetMetadataValue(metadata, key, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return ParseList(raw);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(trimmed);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return document.RootElement
|
||||
.EnumerateArray()
|
||||
.Where(static element => element.ValueKind == JsonValueKind.String)
|
||||
.Select(static element => element.GetString() ?? string.Empty)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return Split(trimmed);
|
||||
}
|
||||
|
||||
private static List<string> Split(string value)
|
||||
{
|
||||
return value
|
||||
.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static item => item.Trim())
|
||||
.Where(static item => item.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool DetermineUsageOnly(ImmutableSortedDictionary<string, string> metadata, bool defaultValue)
|
||||
{
|
||||
if (TryGetMetadataValue(metadata, "policy.selector.usageOnly", out var raw))
|
||||
{
|
||||
return ParseBoolean(raw);
|
||||
}
|
||||
|
||||
if (TryGetMetadataValue(metadata, "delta.usageOnly", out raw))
|
||||
{
|
||||
return ParseBoolean(raw);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string value)
|
||||
{
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
return numeric != 0;
|
||||
}
|
||||
|
||||
return value.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("y", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryGetMetadataValue(
|
||||
ImmutableSortedDictionary<string, string> metadata,
|
||||
string key,
|
||||
out string value)
|
||||
{
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user