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:
@@ -6,34 +6,34 @@ namespace StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Observer-specific configuration applied on top of the shared runtime options.
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public sealed class ZastavaObserverOptions
|
||||
{
|
||||
public const string SectionName = "zastava:observer";
|
||||
|
||||
private const string DefaultContainerdSocket = "unix:///run/containerd/containerd.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Logical node identifier emitted with runtime events (defaults to environment hostname).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string NodeName { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ZASTAVA_NODE_NAME")
|
||||
?? Environment.GetEnvironmentVariable("KUBERNETES_NODE_NAME")
|
||||
?? Environment.MachineName;
|
||||
|
||||
/// <summary>
|
||||
/// Baseline polling interval when watching CRI runtimes.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of runtime events held in the in-memory buffer.
|
||||
/// </summary>
|
||||
[Range(16, 65536)]
|
||||
public int MaxInMemoryBuffer { get; set; } = 2048;
|
||||
|
||||
public const string SectionName = "zastava:observer";
|
||||
|
||||
private const string DefaultContainerdSocket = "unix:///run/containerd/containerd.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Logical node identifier emitted with runtime events (defaults to environment hostname).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string NodeName { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ZASTAVA_NODE_NAME")
|
||||
?? Environment.GetEnvironmentVariable("KUBERNETES_NODE_NAME")
|
||||
?? Environment.MachineName;
|
||||
|
||||
/// <summary>
|
||||
/// Baseline polling interval when watching CRI runtimes.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of runtime events held in the in-memory buffer.
|
||||
/// </summary>
|
||||
[Range(16, 65536)]
|
||||
public int MaxInMemoryBuffer { get; set; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Number of runtime events drained in one batch by downstream publishers.
|
||||
/// </summary>
|
||||
@@ -60,8 +60,8 @@ public sealed class ZastavaObserverOptions
|
||||
|
||||
/// <summary>
|
||||
/// Connectivity/backoff settings applied when CRI endpoints fail temporarily.
|
||||
/// </summary>
|
||||
[Required]
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ObserverBackoffOptions Backoff { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -69,8 +69,8 @@ public sealed class ZastavaObserverOptions
|
||||
/// </summary>
|
||||
[Required]
|
||||
public IList<ContainerRuntimeEndpointOptions> Runtimes { get; set; } = new List<ContainerRuntimeEndpointOptions>
|
||||
{
|
||||
new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "containerd",
|
||||
Engine = ContainerRuntimeEngine.Containerd,
|
||||
@@ -190,66 +190,66 @@ public sealed class ZastavaObserverPostureOptions
|
||||
public sealed class ObserverBackoffOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial backoff delay applied after the first failure.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00")]
|
||||
public TimeSpan Initial { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum backoff delay after repeated failures.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan Max { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Jitter ratio applied to the computed delay (0 disables jitter).
|
||||
/// </summary>
|
||||
[Range(0.0, 0.5)]
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
}
|
||||
|
||||
public sealed class ContainerRuntimeEndpointOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Friendly name used for logging/metrics (defaults to engine identifier).
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime engine backing the endpoint.
|
||||
/// </summary>
|
||||
public ContainerRuntimeEngine Engine { get; set; } = ContainerRuntimeEngine.Containerd;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint URI (unix:///run/containerd/containerd.sock, npipe://./pipe/dockershim, https://127.0.0.1:1234, ...).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Endpoint { get; set; } = "unix:///run/containerd/containerd.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit polling interval for this endpoint (falls back to global PollInterval).
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan? PollInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional connection timeout override.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:01:00")]
|
||||
public TimeSpan? ConnectTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag to allow disabling endpoints without removing configuration entries.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ResolveName()
|
||||
=> string.IsNullOrWhiteSpace(Name) ? Engine.ToString().ToLowerInvariant() : Name!;
|
||||
}
|
||||
|
||||
public enum ContainerRuntimeEngine
|
||||
{
|
||||
Containerd,
|
||||
CriO,
|
||||
Docker
|
||||
}
|
||||
/// Initial backoff delay applied after the first failure.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00")]
|
||||
public TimeSpan Initial { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum backoff delay after repeated failures.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan Max { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Jitter ratio applied to the computed delay (0 disables jitter).
|
||||
/// </summary>
|
||||
[Range(0.0, 0.5)]
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
}
|
||||
|
||||
public sealed class ContainerRuntimeEndpointOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Friendly name used for logging/metrics (defaults to engine identifier).
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime engine backing the endpoint.
|
||||
/// </summary>
|
||||
public ContainerRuntimeEngine Engine { get; set; } = ContainerRuntimeEngine.Containerd;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint URI (unix:///run/containerd/containerd.sock, npipe://./pipe/dockershim, https://127.0.0.1:1234, ...).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Endpoint { get; set; } = "unix:///run/containerd/containerd.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit polling interval for this endpoint (falls back to global PollInterval).
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan? PollInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional connection timeout override.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:01:00")]
|
||||
public TimeSpan? ConnectTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag to allow disabling endpoints without removing configuration entries.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ResolveName()
|
||||
=> string.IsNullOrWhiteSpace(Name) ? Engine.ToString().ToLowerInvariant() : Name!;
|
||||
}
|
||||
|
||||
public enum ContainerRuntimeEngine
|
||||
{
|
||||
Containerd,
|
||||
CriO,
|
||||
Docker
|
||||
}
|
||||
|
||||
@@ -1,134 +1,134 @@
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
|
||||
internal sealed class ContainerStateTracker
|
||||
{
|
||||
private readonly Dictionary<string, ContainerStateEntry> entries = new(StringComparer.Ordinal);
|
||||
|
||||
public void BeginCycle()
|
||||
{
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
entry.SeenInCycle = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ContainerLifecycleEvent? MarkRunning(CriContainerInfo snapshot, DateTimeOffset fallbackTimestamp)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var timestamp = snapshot.StartedAt ?? snapshot.CreatedAt;
|
||||
if (timestamp <= DateTimeOffset.MinValue)
|
||||
{
|
||||
timestamp = fallbackTimestamp;
|
||||
}
|
||||
|
||||
if (!entries.TryGetValue(snapshot.Id, out var entry))
|
||||
{
|
||||
entry = new ContainerStateEntry(snapshot);
|
||||
entries[snapshot.Id] = entry;
|
||||
entry.SeenInCycle = true;
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastStart = timestamp;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
}
|
||||
|
||||
entry.SeenInCycle = true;
|
||||
|
||||
if (timestamp > entry.LastStart)
|
||||
{
|
||||
entry.LastStart = timestamp;
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
}
|
||||
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ContainerLifecycleEvent>> CompleteCycleAsync(
|
||||
Func<string, Task<CriContainerInfo?>> statusProvider,
|
||||
DateTimeOffset fallbackTimestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statusProvider);
|
||||
|
||||
var events = new List<ContainerLifecycleEvent>();
|
||||
foreach (var (containerId, entry) in entries.ToArray())
|
||||
{
|
||||
if (entry.SeenInCycle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CriContainerInfo? status = null;
|
||||
if (entry.LastSnapshot is not null && entry.LastSnapshot.FinishedAt is not null)
|
||||
{
|
||||
status = entry.LastSnapshot;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = await statusProvider(containerId).ConfigureAwait(false) ?? entry.LastSnapshot;
|
||||
}
|
||||
|
||||
var stopTimestamp = status?.FinishedAt ?? fallbackTimestamp;
|
||||
if (stopTimestamp <= DateTimeOffset.MinValue)
|
||||
{
|
||||
stopTimestamp = fallbackTimestamp;
|
||||
}
|
||||
|
||||
if (entry.LastStop is not null && stopTimestamp <= entry.LastStop)
|
||||
{
|
||||
entries.Remove(containerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var snapshot = status ?? entry.LastSnapshot ?? entry.MetadataFallback;
|
||||
var stopEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Stop, stopTimestamp, snapshot);
|
||||
events.Add(stopEvent);
|
||||
|
||||
entry.LastStop = stopTimestamp;
|
||||
entry.State = ContainerLifecycleState.Stopped;
|
||||
entries.Remove(containerId);
|
||||
}
|
||||
|
||||
return events
|
||||
.OrderBy(static e => e.Timestamp)
|
||||
.ThenBy(static e => e.Snapshot.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ContainerStateEntry
|
||||
{
|
||||
public ContainerStateEntry(CriContainerInfo seed)
|
||||
{
|
||||
MetadataFallback = seed;
|
||||
LastSnapshot = seed;
|
||||
}
|
||||
|
||||
public ContainerLifecycleState State { get; set; } = ContainerLifecycleState.Unknown;
|
||||
public bool SeenInCycle { get; set; }
|
||||
public DateTimeOffset LastStart { get; set; } = DateTimeOffset.MinValue;
|
||||
public DateTimeOffset? LastStop { get; set; }
|
||||
public CriContainerInfo MetadataFallback { get; }
|
||||
public CriContainerInfo? LastSnapshot { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ContainerLifecycleState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped
|
||||
}
|
||||
|
||||
internal sealed record ContainerLifecycleEvent(ContainerLifecycleEventKind Kind, DateTimeOffset Timestamp, CriContainerInfo Snapshot);
|
||||
|
||||
internal enum ContainerLifecycleEventKind
|
||||
{
|
||||
Start,
|
||||
Stop
|
||||
}
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
|
||||
internal sealed class ContainerStateTracker
|
||||
{
|
||||
private readonly Dictionary<string, ContainerStateEntry> entries = new(StringComparer.Ordinal);
|
||||
|
||||
public void BeginCycle()
|
||||
{
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
entry.SeenInCycle = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ContainerLifecycleEvent? MarkRunning(CriContainerInfo snapshot, DateTimeOffset fallbackTimestamp)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var timestamp = snapshot.StartedAt ?? snapshot.CreatedAt;
|
||||
if (timestamp <= DateTimeOffset.MinValue)
|
||||
{
|
||||
timestamp = fallbackTimestamp;
|
||||
}
|
||||
|
||||
if (!entries.TryGetValue(snapshot.Id, out var entry))
|
||||
{
|
||||
entry = new ContainerStateEntry(snapshot);
|
||||
entries[snapshot.Id] = entry;
|
||||
entry.SeenInCycle = true;
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastStart = timestamp;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
}
|
||||
|
||||
entry.SeenInCycle = true;
|
||||
|
||||
if (timestamp > entry.LastStart)
|
||||
{
|
||||
entry.LastStart = timestamp;
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
}
|
||||
|
||||
entry.State = ContainerLifecycleState.Running;
|
||||
entry.LastSnapshot = snapshot;
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ContainerLifecycleEvent>> CompleteCycleAsync(
|
||||
Func<string, Task<CriContainerInfo?>> statusProvider,
|
||||
DateTimeOffset fallbackTimestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statusProvider);
|
||||
|
||||
var events = new List<ContainerLifecycleEvent>();
|
||||
foreach (var (containerId, entry) in entries.ToArray())
|
||||
{
|
||||
if (entry.SeenInCycle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CriContainerInfo? status = null;
|
||||
if (entry.LastSnapshot is not null && entry.LastSnapshot.FinishedAt is not null)
|
||||
{
|
||||
status = entry.LastSnapshot;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = await statusProvider(containerId).ConfigureAwait(false) ?? entry.LastSnapshot;
|
||||
}
|
||||
|
||||
var stopTimestamp = status?.FinishedAt ?? fallbackTimestamp;
|
||||
if (stopTimestamp <= DateTimeOffset.MinValue)
|
||||
{
|
||||
stopTimestamp = fallbackTimestamp;
|
||||
}
|
||||
|
||||
if (entry.LastStop is not null && stopTimestamp <= entry.LastStop)
|
||||
{
|
||||
entries.Remove(containerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var snapshot = status ?? entry.LastSnapshot ?? entry.MetadataFallback;
|
||||
var stopEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Stop, stopTimestamp, snapshot);
|
||||
events.Add(stopEvent);
|
||||
|
||||
entry.LastStop = stopTimestamp;
|
||||
entry.State = ContainerLifecycleState.Stopped;
|
||||
entries.Remove(containerId);
|
||||
}
|
||||
|
||||
return events
|
||||
.OrderBy(static e => e.Timestamp)
|
||||
.ThenBy(static e => e.Snapshot.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ContainerStateEntry
|
||||
{
|
||||
public ContainerStateEntry(CriContainerInfo seed)
|
||||
{
|
||||
MetadataFallback = seed;
|
||||
LastSnapshot = seed;
|
||||
}
|
||||
|
||||
public ContainerLifecycleState State { get; set; } = ContainerLifecycleState.Unknown;
|
||||
public bool SeenInCycle { get; set; }
|
||||
public DateTimeOffset LastStart { get; set; } = DateTimeOffset.MinValue;
|
||||
public DateTimeOffset? LastStop { get; set; }
|
||||
public CriContainerInfo MetadataFallback { get; }
|
||||
public CriContainerInfo? LastSnapshot { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ContainerLifecycleState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped
|
||||
}
|
||||
|
||||
internal sealed record ContainerLifecycleEvent(ContainerLifecycleEventKind Kind, DateTimeOffset Timestamp, CriContainerInfo Snapshot);
|
||||
|
||||
internal enum ContainerLifecycleEventKind
|
||||
{
|
||||
Start,
|
||||
Stop
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using StellaOps.Zastava.Observer.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal static class CriConversions
|
||||
{
|
||||
private const long NanosecondsPerTick = 100;
|
||||
|
||||
public static CriContainerInfo ToContainerInfo(Container container)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
return new CriContainerInfo(
|
||||
Id: container.Id ?? string.Empty,
|
||||
PodSandboxId: container.PodSandboxId ?? string.Empty,
|
||||
Name: container.Metadata?.Name ?? string.Empty,
|
||||
Attempt: container.Metadata?.Attempt ?? 0,
|
||||
Image: container.Image?.Image,
|
||||
ImageRef: container.ImageRef,
|
||||
Labels: container.Labels?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal) ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
Annotations: container.Annotations?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal) ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
CreatedAt: FromUnixNanoseconds(container.CreatedAt),
|
||||
StartedAt: null,
|
||||
FinishedAt: null,
|
||||
using StellaOps.Zastava.Observer.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal static class CriConversions
|
||||
{
|
||||
private const long NanosecondsPerTick = 100;
|
||||
|
||||
public static CriContainerInfo ToContainerInfo(Container container)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
return new CriContainerInfo(
|
||||
Id: container.Id ?? string.Empty,
|
||||
PodSandboxId: container.PodSandboxId ?? string.Empty,
|
||||
Name: container.Metadata?.Name ?? string.Empty,
|
||||
Attempt: container.Metadata?.Attempt ?? 0,
|
||||
Image: container.Image?.Image,
|
||||
ImageRef: container.ImageRef,
|
||||
Labels: container.Labels?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal) ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
Annotations: container.Annotations?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal) ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
CreatedAt: FromUnixNanoseconds(container.CreatedAt),
|
||||
StartedAt: null,
|
||||
FinishedAt: null,
|
||||
ExitCode: null,
|
||||
Reason: null,
|
||||
Message: null,
|
||||
Pid: null);
|
||||
}
|
||||
|
||||
public static CriContainerInfo MergeStatus(CriContainerInfo baseline, ContainerStatus? status)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var labels = status.Labels?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
?? baseline.Labels;
|
||||
var annotations = status.Annotations?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
?? baseline.Annotations;
|
||||
|
||||
return baseline with
|
||||
{
|
||||
CreatedAt = status.CreatedAt > 0 ? FromUnixNanoseconds(status.CreatedAt) : baseline.CreatedAt,
|
||||
StartedAt = status.StartedAt > 0 ? FromUnixNanoseconds(status.StartedAt) : baseline.StartedAt,
|
||||
FinishedAt = status.FinishedAt > 0 ? FromUnixNanoseconds(status.FinishedAt) : baseline.FinishedAt,
|
||||
ExitCode = status.ExitCode != 0 ? status.ExitCode : baseline.ExitCode,
|
||||
Reason = string.IsNullOrWhiteSpace(status.Reason) ? baseline.Reason : status.Reason,
|
||||
}
|
||||
|
||||
public static CriContainerInfo MergeStatus(CriContainerInfo baseline, ContainerStatus? status)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
return baseline;
|
||||
}
|
||||
|
||||
var labels = status.Labels?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
?? baseline.Labels;
|
||||
var annotations = status.Annotations?.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
?? baseline.Annotations;
|
||||
|
||||
return baseline with
|
||||
{
|
||||
CreatedAt = status.CreatedAt > 0 ? FromUnixNanoseconds(status.CreatedAt) : baseline.CreatedAt,
|
||||
StartedAt = status.StartedAt > 0 ? FromUnixNanoseconds(status.StartedAt) : baseline.StartedAt,
|
||||
FinishedAt = status.FinishedAt > 0 ? FromUnixNanoseconds(status.FinishedAt) : baseline.FinishedAt,
|
||||
ExitCode = status.ExitCode != 0 ? status.ExitCode : baseline.ExitCode,
|
||||
Reason = string.IsNullOrWhiteSpace(status.Reason) ? baseline.Reason : status.Reason,
|
||||
Message = string.IsNullOrWhiteSpace(status.Message) ? baseline.Message : status.Message,
|
||||
Pid = baseline.Pid,
|
||||
Image = status.Image?.Image ?? baseline.Image,
|
||||
@@ -54,25 +54,25 @@ internal static class CriConversions
|
||||
Labels = labels,
|
||||
Annotations = annotations
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTimeOffset FromUnixNanoseconds(long nanoseconds)
|
||||
{
|
||||
if (nanoseconds <= 0)
|
||||
{
|
||||
return DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
var seconds = Math.DivRem(nanoseconds, 1_000_000_000, out var remainder);
|
||||
var ticks = remainder / NanosecondsPerTick;
|
||||
try
|
||||
{
|
||||
var baseTime = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
return baseTime.AddTicks(ticks);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static DateTimeOffset FromUnixNanoseconds(long nanoseconds)
|
||||
{
|
||||
if (nanoseconds <= 0)
|
||||
{
|
||||
return DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
var seconds = Math.DivRem(nanoseconds, 1_000_000_000, out var remainder);
|
||||
var ticks = remainder / NanosecondsPerTick;
|
||||
try
|
||||
{
|
||||
var baseTime = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
return baseTime.AddTicks(ticks);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal sealed record CriRuntimeIdentity(
|
||||
string RuntimeName,
|
||||
string RuntimeVersion,
|
||||
string RuntimeApiVersion);
|
||||
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal sealed record CriRuntimeIdentity(
|
||||
string RuntimeName,
|
||||
string RuntimeVersion,
|
||||
string RuntimeApiVersion);
|
||||
|
||||
internal sealed record CriContainerInfo(
|
||||
string Id,
|
||||
string PodSandboxId,
|
||||
@@ -23,23 +23,23 @@ internal sealed record CriContainerInfo(
|
||||
string? Reason,
|
||||
string? Message,
|
||||
int? Pid);
|
||||
|
||||
internal static class CriLabelKeys
|
||||
{
|
||||
public const string PodName = "io.kubernetes.pod.name";
|
||||
public const string PodNamespace = "io.kubernetes.pod.namespace";
|
||||
public const string PodUid = "io.kubernetes.pod.uid";
|
||||
public const string ContainerName = "io.kubernetes.container.name";
|
||||
}
|
||||
|
||||
internal static class ContainerRuntimeEngineExtensions
|
||||
{
|
||||
public static string ToEngineString(this ContainerRuntimeEngine engine)
|
||||
=> engine switch
|
||||
{
|
||||
ContainerRuntimeEngine.Containerd => "containerd",
|
||||
ContainerRuntimeEngine.CriO => "cri-o",
|
||||
ContainerRuntimeEngine.Docker => "docker",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
internal static class CriLabelKeys
|
||||
{
|
||||
public const string PodName = "io.kubernetes.pod.name";
|
||||
public const string PodNamespace = "io.kubernetes.pod.namespace";
|
||||
public const string PodUid = "io.kubernetes.pod.uid";
|
||||
public const string ContainerName = "io.kubernetes.container.name";
|
||||
}
|
||||
|
||||
internal static class ContainerRuntimeEngineExtensions
|
||||
{
|
||||
public static string ToEngineString(this ContainerRuntimeEngine engine)
|
||||
=> engine switch
|
||||
{
|
||||
ContainerRuntimeEngine.Containerd => "containerd",
|
||||
ContainerRuntimeEngine.CriO => "cri-o",
|
||||
ContainerRuntimeEngine.Docker => "docker",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,89 +7,89 @@ using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal interface ICriRuntimeClient : IAsyncDisposable
|
||||
{
|
||||
ContainerRuntimeEndpointOptions Endpoint { get; }
|
||||
Task<CriRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CriContainerInfo>> ListContainersAsync(ContainerState state, CancellationToken cancellationToken);
|
||||
Task<CriContainerInfo?> GetContainerStatusAsync(string containerId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
{
|
||||
private static readonly object SwitchLock = new();
|
||||
private static bool http2SwitchApplied;
|
||||
|
||||
private readonly GrpcChannel channel;
|
||||
private readonly RuntimeService.RuntimeServiceClient client;
|
||||
private readonly ILogger<CriRuntimeClient> logger;
|
||||
|
||||
public CriRuntimeClient(ContainerRuntimeEndpointOptions endpoint, ILogger<CriRuntimeClient> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
Endpoint = endpoint;
|
||||
|
||||
EnsureHttp2Switch();
|
||||
channel = CreateChannel(endpoint);
|
||||
client = new RuntimeService.RuntimeServiceClient(channel);
|
||||
}
|
||||
|
||||
public ContainerRuntimeEndpointOptions Endpoint { get; }
|
||||
|
||||
public async Task<CriRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await client.VersionAsync(new VersionRequest(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return new CriRuntimeIdentity(
|
||||
RuntimeName: response.RuntimeName ?? Endpoint.Engine.ToEngineString(),
|
||||
RuntimeVersion: response.RuntimeVersion ?? "unknown",
|
||||
RuntimeApiVersion: response.RuntimeApiVersion ?? response.Version ?? "unknown");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CriContainerInfo>> ListContainersAsync(ContainerState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new ListContainersRequest
|
||||
{
|
||||
Filter = new ContainerFilter
|
||||
{
|
||||
State = new ContainerStateValue
|
||||
{
|
||||
State = state
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.ListContainersAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (response.Containers is null || response.Containers.Count == 0)
|
||||
{
|
||||
return Array.Empty<CriContainerInfo>();
|
||||
}
|
||||
|
||||
return response.Containers
|
||||
.Select(CriConversions.ToContainerInfo)
|
||||
.ToArray();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unimplemented)
|
||||
{
|
||||
logger.LogWarning(ex, "Runtime endpoint {Endpoint} does not support ListContainers for state {State}.", Endpoint.Endpoint, state);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CriContainerInfo?> GetContainerStatusAsync(string containerId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal interface ICriRuntimeClient : IAsyncDisposable
|
||||
{
|
||||
ContainerRuntimeEndpointOptions Endpoint { get; }
|
||||
Task<CriRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CriContainerInfo>> ListContainersAsync(ContainerState state, CancellationToken cancellationToken);
|
||||
Task<CriContainerInfo?> GetContainerStatusAsync(string containerId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
{
|
||||
private static readonly object SwitchLock = new();
|
||||
private static bool http2SwitchApplied;
|
||||
|
||||
private readonly GrpcChannel channel;
|
||||
private readonly RuntimeService.RuntimeServiceClient client;
|
||||
private readonly ILogger<CriRuntimeClient> logger;
|
||||
|
||||
public CriRuntimeClient(ContainerRuntimeEndpointOptions endpoint, ILogger<CriRuntimeClient> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
Endpoint = endpoint;
|
||||
|
||||
EnsureHttp2Switch();
|
||||
channel = CreateChannel(endpoint);
|
||||
client = new RuntimeService.RuntimeServiceClient(channel);
|
||||
}
|
||||
|
||||
public ContainerRuntimeEndpointOptions Endpoint { get; }
|
||||
|
||||
public async Task<CriRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await client.VersionAsync(new VersionRequest(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return new CriRuntimeIdentity(
|
||||
RuntimeName: response.RuntimeName ?? Endpoint.Engine.ToEngineString(),
|
||||
RuntimeVersion: response.RuntimeVersion ?? "unknown",
|
||||
RuntimeApiVersion: response.RuntimeApiVersion ?? response.Version ?? "unknown");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CriContainerInfo>> ListContainersAsync(ContainerState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new ListContainersRequest
|
||||
{
|
||||
Filter = new ContainerFilter
|
||||
{
|
||||
State = new ContainerStateValue
|
||||
{
|
||||
State = state
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.ListContainersAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (response.Containers is null || response.Containers.Count == 0)
|
||||
{
|
||||
return Array.Empty<CriContainerInfo>();
|
||||
}
|
||||
|
||||
return response.Containers
|
||||
.Select(CriConversions.ToContainerInfo)
|
||||
.ToArray();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unimplemented)
|
||||
{
|
||||
logger.LogWarning(ex, "Runtime endpoint {Endpoint} does not support ListContainers for state {State}.", Endpoint.Endpoint, state);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CriContainerInfo?> GetContainerStatusAsync(string containerId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.ContainerStatusAsync(new ContainerStatusRequest
|
||||
{
|
||||
ContainerId = containerId,
|
||||
@@ -99,20 +99,20 @@ internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
if (response.Status is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseline = CriConversions.ToContainerInfo(new Container
|
||||
{
|
||||
Id = response.Status.Id,
|
||||
PodSandboxId = response.Status.Metadata?.Name ?? string.Empty,
|
||||
Metadata = response.Status.Metadata,
|
||||
Image = response.Status.Image,
|
||||
ImageRef = response.Status.ImageRef,
|
||||
Labels = { response.Status.Labels },
|
||||
Annotations = { response.Status.Annotations },
|
||||
CreatedAt = response.Status.CreatedAt
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
var baseline = CriConversions.ToContainerInfo(new Container
|
||||
{
|
||||
Id = response.Status.Id,
|
||||
PodSandboxId = response.Status.Metadata?.Name ?? string.Empty,
|
||||
Metadata = response.Status.Metadata,
|
||||
Image = response.Status.Image,
|
||||
ImageRef = response.Status.ImageRef,
|
||||
Labels = { response.Status.Labels },
|
||||
Annotations = { response.Status.Annotations },
|
||||
CreatedAt = response.Status.CreatedAt
|
||||
});
|
||||
|
||||
var merged = CriConversions.MergeStatus(baseline, response.Status);
|
||||
|
||||
if (response.Info is { Count: > 0 } && TryExtractPid(response.Info, out var pid))
|
||||
@@ -122,11 +122,11 @@ internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
|
||||
return merged;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode is StatusCode.NotFound or StatusCode.DeadlineExceeded)
|
||||
{
|
||||
logger.LogDebug(ex, "Container {ContainerId} no longer available when querying status.", containerId);
|
||||
return null;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode is StatusCode.NotFound or StatusCode.DeadlineExceeded)
|
||||
{
|
||||
logger.LogDebug(ex, "Container {ContainerId} no longer available when querying status.", containerId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractPid(IDictionary<string, string> info, out int pid)
|
||||
@@ -159,7 +159,7 @@ internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
pid = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
@@ -173,82 +173,82 @@ internal sealed class CriRuntimeClient : ICriRuntimeClient
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static void EnsureHttp2Switch()
|
||||
{
|
||||
if (http2SwitchApplied)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SwitchLock)
|
||||
{
|
||||
if (!http2SwitchApplied)
|
||||
{
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
http2SwitchApplied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GrpcChannel CreateChannel(ContainerRuntimeEndpointOptions endpoint)
|
||||
{
|
||||
if (IsUnixEndpoint(endpoint.Endpoint, out var unixPath))
|
||||
{
|
||||
var resolvedPath = unixPath;
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = (context, cancellationToken) => ConnectUnixDomainSocketAsync(resolvedPath, cancellationToken),
|
||||
EnableMultipleHttp2Connections = true
|
||||
};
|
||||
|
||||
|
||||
private static void EnsureHttp2Switch()
|
||||
{
|
||||
if (http2SwitchApplied)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SwitchLock)
|
||||
{
|
||||
if (!http2SwitchApplied)
|
||||
{
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
http2SwitchApplied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GrpcChannel CreateChannel(ContainerRuntimeEndpointOptions endpoint)
|
||||
{
|
||||
if (IsUnixEndpoint(endpoint.Endpoint, out var unixPath))
|
||||
{
|
||||
var resolvedPath = unixPath;
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = (context, cancellationToken) => ConnectUnixDomainSocketAsync(resolvedPath, cancellationToken),
|
||||
EnableMultipleHttp2Connections = true
|
||||
};
|
||||
|
||||
if (endpoint.ConnectTimeout is { } timeout && timeout > TimeSpan.Zero)
|
||||
{
|
||||
handler.ConnectTimeout = timeout;
|
||||
}
|
||||
|
||||
return GrpcChannel.ForAddress("http://unix.local", new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = handler,
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
}
|
||||
|
||||
return GrpcChannel.ForAddress(endpoint.Endpoint, new GrpcChannelOptions
|
||||
{
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsUnixEndpoint(string endpoint, out string path)
|
||||
{
|
||||
if (endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = endpoint["unix://".Length..];
|
||||
return true;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async ValueTask<Stream> ConnectUnixDomainSocketAsync(string unixPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = new UnixDomainSocketEndPoint(unixPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GrpcChannel.ForAddress("http://unix.local", new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = handler,
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
}
|
||||
|
||||
return GrpcChannel.ForAddress(endpoint.Endpoint, new GrpcChannelOptions
|
||||
{
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsUnixEndpoint(string endpoint, out string path)
|
||||
{
|
||||
if (endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = endpoint["unix://".Length..];
|
||||
return true;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async ValueTask<Stream> ConnectUnixDomainSocketAsync(string unixPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = new UnixDomainSocketEndPoint(unixPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal interface ICriRuntimeClientFactory
|
||||
{
|
||||
ICriRuntimeClient Create(ContainerRuntimeEndpointOptions endpoint);
|
||||
}
|
||||
|
||||
internal sealed class CriRuntimeClientFactory : ICriRuntimeClientFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public CriRuntimeClientFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
public ICriRuntimeClient Create(ContainerRuntimeEndpointOptions endpoint)
|
||||
{
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<CriRuntimeClient>>();
|
||||
return new CriRuntimeClient(endpoint, logger);
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
internal interface ICriRuntimeClientFactory
|
||||
{
|
||||
ICriRuntimeClient Create(ContainerRuntimeEndpointOptions endpoint);
|
||||
}
|
||||
|
||||
internal sealed class CriRuntimeClientFactory : ICriRuntimeClientFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public CriRuntimeClientFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
public ICriRuntimeClient Create(ContainerRuntimeEndpointOptions endpoint)
|
||||
{
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<CriRuntimeClient>>();
|
||||
return new CriRuntimeClient(endpoint, logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddZastavaObserver(builder.Configuration);
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal bootstrap worker ensuring runtime core wiring is exercised.
|
||||
/// </summary>
|
||||
internal sealed class ObserverBootstrapService : BackgroundService
|
||||
{
|
||||
private readonly IZastavaLogScopeBuilder logScopeBuilder;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IHostApplicationLifetime applicationLifetime;
|
||||
private readonly ILogger<ObserverBootstrapService> logger;
|
||||
private readonly ZastavaRuntimeOptions runtimeOptions;
|
||||
|
||||
public ObserverBootstrapService(
|
||||
IZastavaLogScopeBuilder logScopeBuilder,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptions<ZastavaRuntimeOptions> runtimeOptions,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<ObserverBootstrapService> logger)
|
||||
{
|
||||
this.logScopeBuilder = logScopeBuilder;
|
||||
this.runtimeMetrics = runtimeMetrics;
|
||||
this.authorityTokenProvider = authorityTokenProvider;
|
||||
this.applicationLifetime = applicationLifetime;
|
||||
this.logger = logger;
|
||||
this.runtimeOptions = runtimeOptions.Value;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var scope = logScopeBuilder.BuildScope(eventId: "observer.bootstrap");
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Zastava observer runtime core initialised for tenant {Tenant}, component {Component}.", runtimeOptions.Tenant, runtimeOptions.Component);
|
||||
logger.LogDebug("Observer metrics meter {MeterName} registered with {TagCount} default tags.", runtimeMetrics.Meter.Name, runtimeMetrics.DefaultTags.Count);
|
||||
}
|
||||
|
||||
// Observer implementation will hook into the authority token provider when connectors arrive.
|
||||
applicationLifetime.ApplicationStarted.Register(() => logger.LogInformation("Observer bootstrap complete."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal bootstrap worker ensuring runtime core wiring is exercised.
|
||||
/// </summary>
|
||||
internal sealed class ObserverBootstrapService : BackgroundService
|
||||
{
|
||||
private readonly IZastavaLogScopeBuilder logScopeBuilder;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IHostApplicationLifetime applicationLifetime;
|
||||
private readonly ILogger<ObserverBootstrapService> logger;
|
||||
private readonly ZastavaRuntimeOptions runtimeOptions;
|
||||
|
||||
public ObserverBootstrapService(
|
||||
IZastavaLogScopeBuilder logScopeBuilder,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptions<ZastavaRuntimeOptions> runtimeOptions,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<ObserverBootstrapService> logger)
|
||||
{
|
||||
this.logScopeBuilder = logScopeBuilder;
|
||||
this.runtimeMetrics = runtimeMetrics;
|
||||
this.authorityTokenProvider = authorityTokenProvider;
|
||||
this.applicationLifetime = applicationLifetime;
|
||||
this.logger = logger;
|
||||
this.runtimeOptions = runtimeOptions.Value;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var scope = logScopeBuilder.BuildScope(eventId: "observer.bootstrap");
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Zastava observer runtime core initialised for tenant {Tenant}, component {Component}.", runtimeOptions.Tenant, runtimeOptions.Component);
|
||||
logger.LogDebug("Observer metrics meter {MeterName} registered with {TagCount} default tags.", runtimeMetrics.Meter.Name, runtimeMetrics.DefaultTags.Count);
|
||||
}
|
||||
|
||||
// Observer implementation will hook into the authority token provider when connectors arrive.
|
||||
applicationLifetime.ApplicationStarted.Register(() => logger.LogInformation("Observer bootstrap complete."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Authority;
|
||||
|
||||
public sealed class AuthorityTokenHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly ILogger<AuthorityTokenHealthCheck> logger;
|
||||
|
||||
public AuthorityTokenHealthCheck(
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
ILogger<AuthorityTokenHealthCheck> logger)
|
||||
{
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy(
|
||||
"Authority token acquired.",
|
||||
data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = token.ExpiresAtUtc?.ToString("O") ?? "static",
|
||||
["tokenType"] = token.TokenType
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to obtain Authority token via runtime core.");
|
||||
return HealthCheckResult.Unhealthy("Failed to obtain Authority token via runtime core.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Authority;
|
||||
|
||||
public sealed class AuthorityTokenHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly ILogger<AuthorityTokenHealthCheck> logger;
|
||||
|
||||
public AuthorityTokenHealthCheck(
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
ILogger<AuthorityTokenHealthCheck> logger)
|
||||
{
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy(
|
||||
"Authority token acquired.",
|
||||
data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = token.ExpiresAtUtc?.ToString("O") ?? "static",
|
||||
["tokenType"] = token.TokenType
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to obtain Authority token via runtime core.");
|
||||
return HealthCheckResult.Unhealthy("Failed to obtain Authority token via runtime core.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public interface IRuntimePolicyClient
|
||||
{
|
||||
Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public interface IRuntimePolicyClient
|
||||
{
|
||||
Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
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.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
internal sealed class RuntimePolicyClient : IRuntimePolicyClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
static RuntimePolicyClient()
|
||||
{
|
||||
SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
}
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly ILogger<RuntimePolicyClient> logger;
|
||||
|
||||
public RuntimePolicyClient(
|
||||
HttpClient httpClient,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
IOptionsMonitor<ZastavaWebhookOptions> webhookOptions,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
ILogger<RuntimePolicyClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
this.runtimeMetrics = runtimeMetrics ?? throw new ArgumentNullException(nameof(runtimeMetrics));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var backend = webhookOptions.CurrentValue.Backend;
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, backend.PolicyPath)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(request, SerializerOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
httpRequest.Headers.Authorization = CreateAuthorizationHeader(token);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("Runtime policy call returned {StatusCode}: {Payload}", (int)response.StatusCode, payload);
|
||||
throw new RuntimePolicyException($"Runtime policy call failed with status {(int)response.StatusCode}", response.StatusCode);
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<RuntimePolicyResponse>(payload, SerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
throw new RuntimePolicyException("Runtime policy response payload was empty or invalid.", response.StatusCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
RecordLatency(stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue CreateAuthorizationHeader(ZastavaOperationalToken token)
|
||||
{
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase) ? "DPoP" : token.TokenType;
|
||||
return new AuthenticationHeaderValue(scheme, token.AccessToken);
|
||||
}
|
||||
|
||||
private void RecordLatency(double elapsedMs)
|
||||
{
|
||||
var tags = runtimeMetrics.DefaultTags
|
||||
.Concat(new[] { new KeyValuePair<string, object?>("endpoint", "policy") })
|
||||
.ToArray();
|
||||
runtimeMetrics.BackendLatencyMs.Record(elapsedMs, tags);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
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.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
internal sealed class RuntimePolicyClient : IRuntimePolicyClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
static RuntimePolicyClient()
|
||||
{
|
||||
SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
}
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly ILogger<RuntimePolicyClient> logger;
|
||||
|
||||
public RuntimePolicyClient(
|
||||
HttpClient httpClient,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
IOptionsMonitor<ZastavaWebhookOptions> webhookOptions,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
ILogger<RuntimePolicyClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
this.runtimeMetrics = runtimeMetrics ?? throw new ArgumentNullException(nameof(runtimeMetrics));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var backend = webhookOptions.CurrentValue.Backend;
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, backend.PolicyPath)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(request, SerializerOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
httpRequest.Headers.Authorization = CreateAuthorizationHeader(token);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("Runtime policy call returned {StatusCode}: {Payload}", (int)response.StatusCode, payload);
|
||||
throw new RuntimePolicyException($"Runtime policy call failed with status {(int)response.StatusCode}", response.StatusCode);
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<RuntimePolicyResponse>(payload, SerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
throw new RuntimePolicyException("Runtime policy response payload was empty or invalid.", response.StatusCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
RecordLatency(stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue CreateAuthorizationHeader(ZastavaOperationalToken token)
|
||||
{
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase) ? "DPoP" : token.TokenType;
|
||||
return new AuthenticationHeaderValue(scheme, token.AccessToken);
|
||||
}
|
||||
|
||||
private void RecordLatency(double elapsedMs)
|
||||
{
|
||||
var tags = runtimeMetrics.DefaultTags
|
||||
.Concat(new[] { new KeyValuePair<string, object?>("endpoint", "policy") })
|
||||
.ToArray();
|
||||
runtimeMetrics.BackendLatencyMs.Record(elapsedMs, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed class RuntimePolicyException : Exception
|
||||
{
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
}
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed class RuntimePolicyException : Exception
|
||||
{
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyRequest
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public required IReadOnlyList<string> Images { get; init; }
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyRequest
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public required IReadOnlyList<string> Images { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyResponse
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyResponse
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int TtlSeconds { get; init; }
|
||||
|
||||
@@ -15,25 +15,25 @@ public sealed record RuntimePolicyResponse
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; init; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyDictionary<string, RuntimePolicyImageResult> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResult>();
|
||||
}
|
||||
|
||||
public sealed record RuntimePolicyImageResult
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool HasSbom { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public AdmissionRekorEvidence? Rekor { get; init; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyDictionary<string, RuntimePolicyImageResult> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResult>();
|
||||
}
|
||||
|
||||
public sealed record RuntimePolicyImageResult
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool HasSbom { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public AdmissionRekorEvidence? Rekor { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder implementation for CSR-based certificate provisioning.
|
||||
/// </summary>
|
||||
public sealed class CsrCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<CsrCertificateSource> _logger;
|
||||
|
||||
public CsrCertificateSource(ILogger<CsrCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.CertificateSigningRequest;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
_logger.LogError("CSR certificate mode is not implemented yet. Configuration requested CSR mode.");
|
||||
throw new NotSupportedException("CSR certificate provisioning is not implemented (tracked by ZASTAVA-WEBHOOK-12-101).");
|
||||
}
|
||||
}
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder implementation for CSR-based certificate provisioning.
|
||||
/// </summary>
|
||||
public sealed class CsrCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<CsrCertificateSource> _logger;
|
||||
|
||||
public CsrCertificateSource(ILogger<CsrCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.CertificateSigningRequest;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
_logger.LogError("CSR certificate mode is not implemented yet. Configuration requested CSR mode.");
|
||||
throw new NotSupportedException("CSR certificate provisioning is not implemented (tracked by ZASTAVA-WEBHOOK-12-101).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public interface IWebhookCertificateProvider
|
||||
{
|
||||
X509Certificate2 GetCertificate();
|
||||
}
|
||||
|
||||
public sealed class WebhookCertificateProvider : IWebhookCertificateProvider
|
||||
{
|
||||
private readonly ILogger<WebhookCertificateProvider> _logger;
|
||||
private readonly ZastavaWebhookTlsOptions _options;
|
||||
private readonly Lazy<X509Certificate2> _certificate;
|
||||
private readonly IWebhookCertificateSource _certificateSource;
|
||||
|
||||
public WebhookCertificateProvider(
|
||||
IOptions<ZastavaWebhookOptions> options,
|
||||
IEnumerable<IWebhookCertificateSource> certificateSources,
|
||||
ILogger<WebhookCertificateProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options.Value.Tls;
|
||||
_certificateSource = certificateSources.FirstOrDefault(source => source.CanHandle(_options.Mode))
|
||||
?? throw new InvalidOperationException($"No certificate source registered for mode {_options.Mode}.");
|
||||
|
||||
_certificate = new Lazy<X509Certificate2>(LoadCertificate, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public X509Certificate2 GetCertificate() => _certificate.Value;
|
||||
|
||||
private X509Certificate2 LoadCertificate()
|
||||
{
|
||||
_logger.LogInformation("Loading webhook TLS certificate using {Mode} mode.", _options.Mode);
|
||||
var certificate = _certificateSource.LoadCertificate(_options);
|
||||
_logger.LogInformation("Loaded webhook TLS certificate with subject {Subject} and thumbprint {Thumbprint}.",
|
||||
certificate.Subject, certificate.Thumbprint);
|
||||
return certificate;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IWebhookCertificateSource
|
||||
{
|
||||
bool CanHandle(ZastavaWebhookTlsMode mode);
|
||||
|
||||
X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options);
|
||||
}
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public interface IWebhookCertificateProvider
|
||||
{
|
||||
X509Certificate2 GetCertificate();
|
||||
}
|
||||
|
||||
public sealed class WebhookCertificateProvider : IWebhookCertificateProvider
|
||||
{
|
||||
private readonly ILogger<WebhookCertificateProvider> _logger;
|
||||
private readonly ZastavaWebhookTlsOptions _options;
|
||||
private readonly Lazy<X509Certificate2> _certificate;
|
||||
private readonly IWebhookCertificateSource _certificateSource;
|
||||
|
||||
public WebhookCertificateProvider(
|
||||
IOptions<ZastavaWebhookOptions> options,
|
||||
IEnumerable<IWebhookCertificateSource> certificateSources,
|
||||
ILogger<WebhookCertificateProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options.Value.Tls;
|
||||
_certificateSource = certificateSources.FirstOrDefault(source => source.CanHandle(_options.Mode))
|
||||
?? throw new InvalidOperationException($"No certificate source registered for mode {_options.Mode}.");
|
||||
|
||||
_certificate = new Lazy<X509Certificate2>(LoadCertificate, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public X509Certificate2 GetCertificate() => _certificate.Value;
|
||||
|
||||
private X509Certificate2 LoadCertificate()
|
||||
{
|
||||
_logger.LogInformation("Loading webhook TLS certificate using {Mode} mode.", _options.Mode);
|
||||
var certificate = _certificateSource.LoadCertificate(_options);
|
||||
_logger.LogInformation("Loaded webhook TLS certificate with subject {Subject} and thumbprint {Thumbprint}.",
|
||||
certificate.Subject, certificate.Thumbprint);
|
||||
return certificate;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IWebhookCertificateSource
|
||||
{
|
||||
bool CanHandle(ZastavaWebhookTlsMode mode);
|
||||
|
||||
X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options);
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class SecretFileCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<SecretFileCertificateSource> _logger;
|
||||
|
||||
public SecretFileCertificateSource(ILogger<SecretFileCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.PfxPath))
|
||||
{
|
||||
return LoadFromPfx(options.PfxPath, options.PfxPassword);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CertificatePath) || string.IsNullOrWhiteSpace(options.PrivateKeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("TLS mode 'Secret' requires either a PFX bundle or both PEM certificate and private key paths.");
|
||||
}
|
||||
|
||||
if (!File.Exists(options.CertificatePath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate file not found.", options.CertificatePath);
|
||||
}
|
||||
|
||||
if (!File.Exists(options.PrivateKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate private key file not found.", options.PrivateKeyPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var certificate = X509Certificate2.CreateFromPemFile(options.CertificatePath, options.PrivateKeyPath)
|
||||
.WithExportablePrivateKey();
|
||||
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PEM secret files.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PEM files {CertPath} / {KeyPath}.",
|
||||
options.CertificatePath, options.PrivateKeyPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private X509Certificate2 LoadFromPfx(string pfxPath, string? password)
|
||||
{
|
||||
if (!File.Exists(pfxPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate PFX bundle not found.", pfxPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var storageFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet;
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, storageFlags);
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PFX bundle.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PFX bundle {PfxPath}.", pfxPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class X509Certificate2Extensions
|
||||
{
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class SecretFileCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<SecretFileCertificateSource> _logger;
|
||||
|
||||
public SecretFileCertificateSource(ILogger<SecretFileCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.PfxPath))
|
||||
{
|
||||
return LoadFromPfx(options.PfxPath, options.PfxPassword);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CertificatePath) || string.IsNullOrWhiteSpace(options.PrivateKeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("TLS mode 'Secret' requires either a PFX bundle or both PEM certificate and private key paths.");
|
||||
}
|
||||
|
||||
if (!File.Exists(options.CertificatePath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate file not found.", options.CertificatePath);
|
||||
}
|
||||
|
||||
if (!File.Exists(options.PrivateKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate private key file not found.", options.PrivateKeyPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var certificate = X509Certificate2.CreateFromPemFile(options.CertificatePath, options.PrivateKeyPath)
|
||||
.WithExportablePrivateKey();
|
||||
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PEM secret files.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PEM files {CertPath} / {KeyPath}.",
|
||||
options.CertificatePath, options.PrivateKeyPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private X509Certificate2 LoadFromPfx(string pfxPath, string? password)
|
||||
{
|
||||
if (!File.Exists(pfxPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate PFX bundle not found.", pfxPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var storageFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet;
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, storageFlags);
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PFX bundle.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PFX bundle {PfxPath}.", pfxPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class X509Certificate2Extensions
|
||||
{
|
||||
public static X509Certificate2 WithExportablePrivateKey(this X509Certificate2 certificate)
|
||||
{
|
||||
// Ensure the private key is exportable for Kestrel; CreateFromPemFile returns a temporary key material otherwise.
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||
|
||||
public WebhookCertificateHealthCheck(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
ILogger<WebhookCertificateHealthCheck> logger)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var certificate = _certificateProvider.GetCertificate();
|
||||
var expires = certificate.NotAfter.ToUniversalTime();
|
||||
var remaining = expires - DateTimeOffset.UtcNow;
|
||||
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Webhook certificate expired.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O")
|
||||
}));
|
||||
}
|
||||
|
||||
if (remaining <= _expiryThreshold)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Webhook certificate nearing expiry.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Webhook certificate valid.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate.");
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Failed to load webhook certificate.", ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||
|
||||
public WebhookCertificateHealthCheck(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
ILogger<WebhookCertificateHealthCheck> logger)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var certificate = _certificateProvider.GetCertificate();
|
||||
var expires = certificate.NotAfter.ToUniversalTime();
|
||||
var remaining = expires - DateTimeOffset.UtcNow;
|
||||
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Webhook certificate expired.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O")
|
||||
}));
|
||||
}
|
||||
|
||||
if (remaining <= _expiryThreshold)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Webhook certificate nearing expiry.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Webhook certificate valid.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate.");
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Failed to load webhook certificate.", ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
public sealed class ZastavaWebhookOptions
|
||||
{
|
||||
public const string SectionName = "zastava:webhook";
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookTlsOptions Tls { get; init; } = new();
|
||||
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
public sealed class ZastavaWebhookOptions
|
||||
{
|
||||
public const string SectionName = "zastava:webhook";
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookTlsOptions Tls { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookAuthorityOptions Authority { get; init; } = new();
|
||||
|
||||
@@ -22,133 +22,133 @@ public sealed class ZastavaWebhookOptions
|
||||
[Required]
|
||||
public ZastavaSurfaceSecretsOptions Secrets { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookAdmissionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Namespaces that default to fail-open when backend calls fail.
|
||||
/// </summary>
|
||||
public HashSet<string> FailOpenNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces that must fail-closed even if the global default is fail-open.
|
||||
/// </summary>
|
||||
public HashSet<string> FailClosedNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Global fail-open toggle. When true, namespaces not in <see cref="FailClosedNamespaces"/> will allow requests on backend failures.
|
||||
/// </summary>
|
||||
public bool FailOpenByDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables tag resolution to immutable digests when set.
|
||||
/// </summary>
|
||||
public bool ResolveTags { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional cache seed path for pre-computed runtime verdicts.
|
||||
/// </summary>
|
||||
public string? CacheSeedPath { get; init; }
|
||||
}
|
||||
|
||||
public enum ZastavaWebhookTlsMode
|
||||
{
|
||||
Secret = 0,
|
||||
CertificateSigningRequest = 1
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsOptions
|
||||
{
|
||||
[Required]
|
||||
public ZastavaWebhookTlsMode Mode { get; init; } = ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
/// <summary>
|
||||
/// PEM certificate path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM private key path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? PrivateKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PFX bundle path; takes precedence over PEM values when provided.
|
||||
/// </summary>
|
||||
public string? PfxPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password for the PFX bundle.
|
||||
/// </summary>
|
||||
public string? PfxPassword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CA bundle path to present to Kubernetes when configuring webhook registration.
|
||||
/// </summary>
|
||||
public string? CaBundlePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CSR related settings when <see cref="Mode"/> equals <see cref="ZastavaWebhookTlsMode.CertificateSigningRequest"/>.
|
||||
/// </summary>
|
||||
public ZastavaWebhookTlsCsrOptions Csr { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsCsrOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Kubernetes namespace that owns the <c>CertificateSigningRequest</c> object.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Namespace { get; init; } = "stellaops";
|
||||
|
||||
/// <summary>
|
||||
/// CSR object name; defaults to <c>zastava-webhook</c>.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
[MaxLength(253)]
|
||||
public string Name { get; init; } = "zastava-webhook";
|
||||
|
||||
/// <summary>
|
||||
/// DNS names placed in the CSR <c>subjectAltName</c>.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] DnsNames { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Where the signed certificate is persisted after approval (mounted emptyDir).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string PersistPath { get; init; } = "/var/run/zastava-webhook/certs";
|
||||
}
|
||||
|
||||
|
||||
public sealed class ZastavaWebhookAdmissionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Namespaces that default to fail-open when backend calls fail.
|
||||
/// </summary>
|
||||
public HashSet<string> FailOpenNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces that must fail-closed even if the global default is fail-open.
|
||||
/// </summary>
|
||||
public HashSet<string> FailClosedNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Global fail-open toggle. When true, namespaces not in <see cref="FailClosedNamespaces"/> will allow requests on backend failures.
|
||||
/// </summary>
|
||||
public bool FailOpenByDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables tag resolution to immutable digests when set.
|
||||
/// </summary>
|
||||
public bool ResolveTags { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional cache seed path for pre-computed runtime verdicts.
|
||||
/// </summary>
|
||||
public string? CacheSeedPath { get; init; }
|
||||
}
|
||||
|
||||
public enum ZastavaWebhookTlsMode
|
||||
{
|
||||
Secret = 0,
|
||||
CertificateSigningRequest = 1
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsOptions
|
||||
{
|
||||
[Required]
|
||||
public ZastavaWebhookTlsMode Mode { get; init; } = ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
/// <summary>
|
||||
/// PEM certificate path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM private key path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? PrivateKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PFX bundle path; takes precedence over PEM values when provided.
|
||||
/// </summary>
|
||||
public string? PfxPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password for the PFX bundle.
|
||||
/// </summary>
|
||||
public string? PfxPassword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CA bundle path to present to Kubernetes when configuring webhook registration.
|
||||
/// </summary>
|
||||
public string? CaBundlePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CSR related settings when <see cref="Mode"/> equals <see cref="ZastavaWebhookTlsMode.CertificateSigningRequest"/>.
|
||||
/// </summary>
|
||||
public ZastavaWebhookTlsCsrOptions Csr { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsCsrOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Kubernetes namespace that owns the <c>CertificateSigningRequest</c> object.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Namespace { get; init; } = "stellaops";
|
||||
|
||||
/// <summary>
|
||||
/// CSR object name; defaults to <c>zastava-webhook</c>.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
[MaxLength(253)]
|
||||
public string Name { get; init; } = "zastava-webhook";
|
||||
|
||||
/// <summary>
|
||||
/// DNS names placed in the CSR <c>subjectAltName</c>.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] DnsNames { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Where the signed certificate is persisted after approval (mounted emptyDir).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string PersistPath { get; init; } = "/var/run/zastava-webhook/certs";
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL for token acquisition.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public Uri Issuer { get; init; } = new("https://authority.internal");
|
||||
|
||||
/// <summary>
|
||||
/// Audience that tokens must target.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] Audience { get; init; } = new[] { "scanner", "zastava" };
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to static OpTok for bootstrap environments.
|
||||
/// </summary>
|
||||
public string? StaticTokenPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional literal token value (test only). Takes precedence over <see cref="StaticTokenPath"/>.
|
||||
/// </summary>
|
||||
public string? StaticTokenValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Interval for refreshing cached tokens before expiry.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "1", "3600")]
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public Uri Issuer { get; init; } = new("https://authority.internal");
|
||||
|
||||
/// <summary>
|
||||
/// Audience that tokens must target.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] Audience { get; init; } = new[] { "scanner", "zastava" };
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to static OpTok for bootstrap environments.
|
||||
/// </summary>
|
||||
public string? StaticTokenPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional literal token value (test only). Takes precedence over <see cref="StaticTokenPath"/>.
|
||||
/// </summary>
|
||||
public string? StaticTokenValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Interval for refreshing cached tokens before expiry.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "1", "3600")]
|
||||
public double RefreshSkewSeconds { get; init; } = TimeSpan.FromMinutes(5).TotalSeconds;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures legacy webhook authority options propagate to runtime options when not explicitly configured.
|
||||
/// </summary>
|
||||
internal sealed class WebhookRuntimeOptionsPostConfigure : IPostConfigureOptions<ZastavaRuntimeOptions>
|
||||
{
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
|
||||
public WebhookRuntimeOptionsPostConfigure(IOptionsMonitor<ZastavaWebhookOptions> webhookOptions)
|
||||
{
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
}
|
||||
|
||||
public void PostConfigure(string? name, ZastavaRuntimeOptions runtimeOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeOptions);
|
||||
|
||||
var snapshot = webhookOptions.Get(name ?? Options.DefaultName);
|
||||
var source = snapshot.Authority;
|
||||
if (source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeOptions.Authority ??= new ZastavaAuthorityOptions();
|
||||
var authority = runtimeOptions.Authority;
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenValue, source.StaticTokenValue))
|
||||
{
|
||||
authority.StaticTokenValue = source.StaticTokenValue;
|
||||
}
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenPath, source.StaticTokenPath))
|
||||
{
|
||||
authority.StaticTokenPath = source.StaticTokenPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source.StaticTokenValue) || !string.IsNullOrWhiteSpace(source.StaticTokenPath))
|
||||
{
|
||||
authority.AllowStaticTokenFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldCopyStaticTokenValue(string? current, string? source)
|
||||
=> string.IsNullOrWhiteSpace(current) && !string.IsNullOrWhiteSpace(source);
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures legacy webhook authority options propagate to runtime options when not explicitly configured.
|
||||
/// </summary>
|
||||
internal sealed class WebhookRuntimeOptionsPostConfigure : IPostConfigureOptions<ZastavaRuntimeOptions>
|
||||
{
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
|
||||
public WebhookRuntimeOptionsPostConfigure(IOptionsMonitor<ZastavaWebhookOptions> webhookOptions)
|
||||
{
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
}
|
||||
|
||||
public void PostConfigure(string? name, ZastavaRuntimeOptions runtimeOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeOptions);
|
||||
|
||||
var snapshot = webhookOptions.Get(name ?? Options.DefaultName);
|
||||
var source = snapshot.Authority;
|
||||
if (source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeOptions.Authority ??= new ZastavaAuthorityOptions();
|
||||
var authority = runtimeOptions.Authority;
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenValue, source.StaticTokenValue))
|
||||
{
|
||||
authority.StaticTokenValue = source.StaticTokenValue;
|
||||
}
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenPath, source.StaticTokenPath))
|
||||
{
|
||||
authority.StaticTokenPath = source.StaticTokenPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source.StaticTokenValue) || !string.IsNullOrWhiteSpace(source.StaticTokenPath))
|
||||
{
|
||||
authority.AllowStaticTokenFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldCopyStaticTokenValue(string? current, string? source)
|
||||
=> string.IsNullOrWhiteSpace(current) && !string.IsNullOrWhiteSpace(source);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@ public sealed class StartupValidationHostedService : IHostedService
|
||||
await _authorityTokenProvider.GetAsync(audience, authority.Scopes, cancellationToken);
|
||||
_logger.LogInformation("Webhook startup validation complete.");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -6,63 +6,63 @@ using StellaOps.Zastava.Webhook.Admission;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddZastavaWebhook(builder.Configuration);
|
||||
|
||||
builder.WebHost.ConfigureKestrel((context, options) =>
|
||||
{
|
||||
options.AddServerHeader = false;
|
||||
options.Limits.MinRequestBodyDataRate = null; // Admission payloads are small; relax defaults for determinism.
|
||||
|
||||
options.ConfigureHttpsDefaults(httpsOptions =>
|
||||
{
|
||||
var certificateProvider = options.ApplicationServices?.GetRequiredService<IWebhookCertificateProvider>()
|
||||
?? throw new InvalidOperationException("Webhook certificate provider unavailable.");
|
||||
|
||||
httpsOptions.SslProtocols = SslProtocols.Tls13;
|
||||
httpsOptions.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.NoCertificate;
|
||||
httpsOptions.CheckCertificateRevocation = false; // Kubernetes API server terminates client auth; revocation handled upstream.
|
||||
httpsOptions.ServerCertificate = certificateProvider.GetCertificate();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
// Health endpoints.
|
||||
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false
|
||||
});
|
||||
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false,
|
||||
Predicate = _ => false
|
||||
});
|
||||
|
||||
|
||||
builder.WebHost.ConfigureKestrel((context, options) =>
|
||||
{
|
||||
options.AddServerHeader = false;
|
||||
options.Limits.MinRequestBodyDataRate = null; // Admission payloads are small; relax defaults for determinism.
|
||||
|
||||
options.ConfigureHttpsDefaults(httpsOptions =>
|
||||
{
|
||||
var certificateProvider = options.ApplicationServices?.GetRequiredService<IWebhookCertificateProvider>()
|
||||
?? throw new InvalidOperationException("Webhook certificate provider unavailable.");
|
||||
|
||||
httpsOptions.SslProtocols = SslProtocols.Tls13;
|
||||
httpsOptions.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.NoCertificate;
|
||||
httpsOptions.CheckCertificateRevocation = false; // Kubernetes API server terminates client auth; revocation handled upstream.
|
||||
httpsOptions.ServerCertificate = certificateProvider.GetCertificate();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
// Health endpoints.
|
||||
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false
|
||||
});
|
||||
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false,
|
||||
Predicate = _ => false
|
||||
});
|
||||
|
||||
app.MapPost("/admission", AdmissionEndpoint.HandleAsync)
|
||||
.WithName("AdmissionReview");
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new { status = "ok", service = "zastava-webhook" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new { status = "ok", service = "zastava-webhook" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Authority client configuration shared by Zastava runtime components.
|
||||
/// </summary>
|
||||
public sealed class ZastavaAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Uri Issuer { get; set; } = new("https://authority.internal");
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client identifier used by runtime services.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string ClientId { get; set; } = "zastava-runtime";
|
||||
|
||||
/// <summary>
|
||||
/// Optional client secret when using confidential clients.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audience claims required on issued tokens.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] Audience { get; set; } = new[] { "scanner" };
|
||||
|
||||
/// <summary>
|
||||
/// Additional scopes requested for the runtime plane.
|
||||
/// </summary>
|
||||
public string[] Scopes { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Seconds before expiry when a cached token should be refreshed.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "0", "3600")]
|
||||
public double RefreshSkewSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Require the Authority to issue DPoP (proof-of-possession) tokens.
|
||||
/// </summary>
|
||||
public bool RequireDpop { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require the Authority client to present mTLS during token acquisition.
|
||||
/// </summary>
|
||||
public bool RequireMutualTls { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow falling back to static tokens when Authority is unavailable.
|
||||
/// </summary>
|
||||
public bool AllowStaticTokenFallback { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a static fallback token (PEM/plain text).
|
||||
/// </summary>
|
||||
public string? StaticTokenPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional literal static token (test/bootstrap only). Takes precedence over <see cref="StaticTokenPath"/>.
|
||||
/// </summary>
|
||||
public string? StaticTokenValue { get; set; }
|
||||
}
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Authority client configuration shared by Zastava runtime components.
|
||||
/// </summary>
|
||||
public sealed class ZastavaAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Uri Issuer { get; set; } = new("https://authority.internal");
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client identifier used by runtime services.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string ClientId { get; set; } = "zastava-runtime";
|
||||
|
||||
/// <summary>
|
||||
/// Optional client secret when using confidential clients.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audience claims required on issued tokens.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] Audience { get; set; } = new[] { "scanner" };
|
||||
|
||||
/// <summary>
|
||||
/// Additional scopes requested for the runtime plane.
|
||||
/// </summary>
|
||||
public string[] Scopes { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Seconds before expiry when a cached token should be refreshed.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "0", "3600")]
|
||||
public double RefreshSkewSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Require the Authority to issue DPoP (proof-of-possession) tokens.
|
||||
/// </summary>
|
||||
public bool RequireDpop { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require the Authority client to present mTLS during token acquisition.
|
||||
/// </summary>
|
||||
public bool RequireMutualTls { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow falling back to static tokens when Authority is unavailable.
|
||||
/// </summary>
|
||||
public bool AllowStaticTokenFallback { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a static fallback token (PEM/plain text).
|
||||
/// </summary>
|
||||
public string? StaticTokenPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional literal static token (test/bootstrap only). Takes precedence over <see cref="StaticTokenPath"/>.
|
||||
/// </summary>
|
||||
public string? StaticTokenValue { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Common runtime configuration shared by Zastava components (observer, webhook, agent).
|
||||
/// </summary>
|
||||
public sealed class ZastavaRuntimeOptions
|
||||
{
|
||||
public const string SectionName = "zastava:runtime";
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier used for scoping logs and metrics.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Tenant { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Deployment environment (prod, staging, etc.) used in telemetry dimensions.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Environment { get; set; } = "local";
|
||||
|
||||
/// <summary>
|
||||
/// Component name (observer/webhook/agent) injected into scopes and metrics.
|
||||
/// </summary>
|
||||
public string? Component { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional deployment identifier (cluster, region, etc.).
|
||||
/// </summary>
|
||||
public string? Deployment { get; set; }
|
||||
|
||||
[Required]
|
||||
public ZastavaRuntimeLoggingOptions Logging { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaRuntimeMetricsOptions Metrics { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaAuthorityOptions Authority { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaRuntimeLoggingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether scopes should be enabled on the logger factory.
|
||||
/// </summary>
|
||||
public bool IncludeScopes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether activity tracking metadata (TraceId/SpanId) should be captured.
|
||||
/// </summary>
|
||||
public bool IncludeActivityTracking { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional static key/value pairs appended to every log scope.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> StaticScope { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class ZastavaRuntimeMetricsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables metrics emission.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Meter name used for all runtime instrumentation.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string MeterName { get; init; } = "StellaOps.Zastava";
|
||||
|
||||
/// <summary>
|
||||
/// Optional meter semantic version.
|
||||
/// </summary>
|
||||
public string? MeterVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Common dimensions attached to every metric emitted by the runtime plane.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> CommonTags { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Common runtime configuration shared by Zastava components (observer, webhook, agent).
|
||||
/// </summary>
|
||||
public sealed class ZastavaRuntimeOptions
|
||||
{
|
||||
public const string SectionName = "zastava:runtime";
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier used for scoping logs and metrics.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Tenant { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Deployment environment (prod, staging, etc.) used in telemetry dimensions.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Environment { get; set; } = "local";
|
||||
|
||||
/// <summary>
|
||||
/// Component name (observer/webhook/agent) injected into scopes and metrics.
|
||||
/// </summary>
|
||||
public string? Component { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional deployment identifier (cluster, region, etc.).
|
||||
/// </summary>
|
||||
public string? Deployment { get; set; }
|
||||
|
||||
[Required]
|
||||
public ZastavaRuntimeLoggingOptions Logging { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaRuntimeMetricsOptions Metrics { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaAuthorityOptions Authority { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaRuntimeLoggingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether scopes should be enabled on the logger factory.
|
||||
/// </summary>
|
||||
public bool IncludeScopes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether activity tracking metadata (TraceId/SpanId) should be captured.
|
||||
/// </summary>
|
||||
public bool IncludeActivityTracking { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional static key/value pairs appended to every log scope.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> StaticScope { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class ZastavaRuntimeMetricsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables metrics emission.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Meter name used for all runtime instrumentation.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string MeterName { get; init; } = "StellaOps.Zastava";
|
||||
|
||||
/// <summary>
|
||||
/// Optional meter semantic version.
|
||||
/// </summary>
|
||||
public string? MeterVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Common dimensions attached to every metric emitted by the runtime plane.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> CommonTags { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Envelope returned by the admission webhook to the Kubernetes API server.
|
||||
/// </summary>
|
||||
public sealed record class AdmissionDecisionEnvelope
|
||||
{
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
public required AdmissionDecision Decision { get; init; }
|
||||
|
||||
public static AdmissionDecisionEnvelope Create(AdmissionDecision decision, ZastavaContractVersions.ContractVersion contract)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
return new AdmissionDecisionEnvelope
|
||||
{
|
||||
SchemaVersion = contract.ToString(),
|
||||
Decision = decision
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsSupported()
|
||||
=> ZastavaContractVersions.IsAdmissionDecisionSupported(SchemaVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical admission decision payload.
|
||||
/// </summary>
|
||||
public sealed record class AdmissionDecision
|
||||
{
|
||||
public required string AdmissionId { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
public required string PodSpecDigest { get; init; }
|
||||
|
||||
public IReadOnlyList<AdmissionImageVerdict> Images { get; init; } = Array.Empty<AdmissionImageVerdict>();
|
||||
|
||||
public required AdmissionDecisionOutcome Decision { get; init; }
|
||||
|
||||
public int TtlSeconds { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public enum AdmissionDecisionOutcome
|
||||
{
|
||||
Allow,
|
||||
Deny
|
||||
}
|
||||
|
||||
public sealed record class AdmissionImageVerdict
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required string Resolved { get; init; }
|
||||
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool HasSbomReferrers { get; init; }
|
||||
|
||||
public PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
|
||||
public AdmissionRekorEvidence? Rekor { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public enum PolicyVerdict
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record class AdmissionRekorEvidence
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public bool? Verified { get; init; }
|
||||
}
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Envelope returned by the admission webhook to the Kubernetes API server.
|
||||
/// </summary>
|
||||
public sealed record class AdmissionDecisionEnvelope
|
||||
{
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
public required AdmissionDecision Decision { get; init; }
|
||||
|
||||
public static AdmissionDecisionEnvelope Create(AdmissionDecision decision, ZastavaContractVersions.ContractVersion contract)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
return new AdmissionDecisionEnvelope
|
||||
{
|
||||
SchemaVersion = contract.ToString(),
|
||||
Decision = decision
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsSupported()
|
||||
=> ZastavaContractVersions.IsAdmissionDecisionSupported(SchemaVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical admission decision payload.
|
||||
/// </summary>
|
||||
public sealed record class AdmissionDecision
|
||||
{
|
||||
public required string AdmissionId { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
public required string PodSpecDigest { get; init; }
|
||||
|
||||
public IReadOnlyList<AdmissionImageVerdict> Images { get; init; } = Array.Empty<AdmissionImageVerdict>();
|
||||
|
||||
public required AdmissionDecisionOutcome Decision { get; init; }
|
||||
|
||||
public int TtlSeconds { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public enum AdmissionDecisionOutcome
|
||||
{
|
||||
Allow,
|
||||
Deny
|
||||
}
|
||||
|
||||
public sealed record class AdmissionImageVerdict
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required string Resolved { get; init; }
|
||||
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool HasSbomReferrers { get; init; }
|
||||
|
||||
public PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
|
||||
public AdmissionRekorEvidence? Rekor { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public enum PolicyVerdict
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record class AdmissionRekorEvidence
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public bool? Verified { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Envelope published by the observer towards Scanner runtime ingestion.
|
||||
/// </summary>
|
||||
public sealed record class RuntimeEventEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract identifier consumed by negotiation logic (<c>zastava.runtime.event@v1</c>).
|
||||
/// </summary>
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime event payload.
|
||||
/// </summary>
|
||||
public required RuntimeEvent Event { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an envelope using the provided runtime contract version.
|
||||
/// </summary>
|
||||
public static RuntimeEventEnvelope Create(RuntimeEvent runtimeEvent, ZastavaContractVersions.ContractVersion contract)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
return new RuntimeEventEnvelope
|
||||
{
|
||||
SchemaVersion = contract.ToString(),
|
||||
Event = runtimeEvent
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the envelope schema is supported by the current runtime.
|
||||
/// </summary>
|
||||
public bool IsSupported()
|
||||
=> ZastavaContractVersions.IsRuntimeEventSupported(SchemaVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical runtime event emitted by the observer.
|
||||
/// </summary>
|
||||
public sealed record class RuntimeEvent
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
|
||||
public required DateTimeOffset When { get; init; }
|
||||
|
||||
public required RuntimeEventKind Kind { get; init; }
|
||||
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
public required string Node { get; init; }
|
||||
|
||||
public required RuntimeEngine Runtime { get; init; }
|
||||
|
||||
public required RuntimeWorkload Workload { get; init; }
|
||||
|
||||
public RuntimeProcess? Process { get; init; }
|
||||
|
||||
[JsonPropertyName("loadedLibs")]
|
||||
public IReadOnlyList<RuntimeLoadedLibrary> LoadedLibraries { get; init; } = Array.Empty<RuntimeLoadedLibrary>();
|
||||
|
||||
public RuntimePosture? Posture { get; init; }
|
||||
|
||||
public RuntimeDelta? Delta { get; init; }
|
||||
|
||||
public IReadOnlyList<RuntimeEvidence> Evidence { get; init; } = Array.Empty<RuntimeEvidence>();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public enum RuntimeEventKind
|
||||
{
|
||||
ContainerStart,
|
||||
ContainerStop,
|
||||
Drift,
|
||||
PolicyViolation,
|
||||
AttestationStatus
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEngine
|
||||
{
|
||||
public required string Engine { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeWorkload
|
||||
{
|
||||
public required string Platform { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
public string? Pod { get; init; }
|
||||
|
||||
public string? Container { get; init; }
|
||||
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
public RuntimeWorkloadOwner? Owner { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeWorkloadOwner
|
||||
{
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Envelope published by the observer towards Scanner runtime ingestion.
|
||||
/// </summary>
|
||||
public sealed record class RuntimeEventEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract identifier consumed by negotiation logic (<c>zastava.runtime.event@v1</c>).
|
||||
/// </summary>
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime event payload.
|
||||
/// </summary>
|
||||
public required RuntimeEvent Event { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an envelope using the provided runtime contract version.
|
||||
/// </summary>
|
||||
public static RuntimeEventEnvelope Create(RuntimeEvent runtimeEvent, ZastavaContractVersions.ContractVersion contract)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
return new RuntimeEventEnvelope
|
||||
{
|
||||
SchemaVersion = contract.ToString(),
|
||||
Event = runtimeEvent
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the envelope schema is supported by the current runtime.
|
||||
/// </summary>
|
||||
public bool IsSupported()
|
||||
=> ZastavaContractVersions.IsRuntimeEventSupported(SchemaVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical runtime event emitted by the observer.
|
||||
/// </summary>
|
||||
public sealed record class RuntimeEvent
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
|
||||
public required DateTimeOffset When { get; init; }
|
||||
|
||||
public required RuntimeEventKind Kind { get; init; }
|
||||
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
public required string Node { get; init; }
|
||||
|
||||
public required RuntimeEngine Runtime { get; init; }
|
||||
|
||||
public required RuntimeWorkload Workload { get; init; }
|
||||
|
||||
public RuntimeProcess? Process { get; init; }
|
||||
|
||||
[JsonPropertyName("loadedLibs")]
|
||||
public IReadOnlyList<RuntimeLoadedLibrary> LoadedLibraries { get; init; } = Array.Empty<RuntimeLoadedLibrary>();
|
||||
|
||||
public RuntimePosture? Posture { get; init; }
|
||||
|
||||
public RuntimeDelta? Delta { get; init; }
|
||||
|
||||
public IReadOnlyList<RuntimeEvidence> Evidence { get; init; } = Array.Empty<RuntimeEvidence>();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public enum RuntimeEventKind
|
||||
{
|
||||
ContainerStart,
|
||||
ContainerStop,
|
||||
Drift,
|
||||
PolicyViolation,
|
||||
AttestationStatus
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEngine
|
||||
{
|
||||
public required string Engine { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeWorkload
|
||||
{
|
||||
public required string Platform { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
public string? Pod { get; init; }
|
||||
|
||||
public string? Container { get; init; }
|
||||
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
public RuntimeWorkloadOwner? Owner { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeWorkloadOwner
|
||||
{
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeProcess
|
||||
{
|
||||
public int Pid { get; init; }
|
||||
@@ -120,62 +120,62 @@ public sealed record class RuntimeProcess
|
||||
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEntryTrace
|
||||
{
|
||||
public string? File { get; init; }
|
||||
|
||||
public int? Line { get; init; }
|
||||
|
||||
public string? Op { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeLoadedLibrary
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
|
||||
public long? Inode { get; init; }
|
||||
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimePosture
|
||||
{
|
||||
public bool? ImageSigned { get; init; }
|
||||
|
||||
public string? SbomReferrer { get; init; }
|
||||
|
||||
public RuntimeAttestation? Attestation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeAttestation
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public bool? Verified { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeDelta
|
||||
{
|
||||
public string? BaselineImageDigest { get; init; }
|
||||
|
||||
public IReadOnlyList<string> ChangedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<RuntimeNewBinary> NewBinaries { get; init; } = Array.Empty<RuntimeNewBinary>();
|
||||
}
|
||||
|
||||
public sealed record class RuntimeNewBinary
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEvidence
|
||||
{
|
||||
public required string Signal { get; init; }
|
||||
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEntryTrace
|
||||
{
|
||||
public string? File { get; init; }
|
||||
|
||||
public int? Line { get; init; }
|
||||
|
||||
public string? Op { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeLoadedLibrary
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
|
||||
public long? Inode { get; init; }
|
||||
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimePosture
|
||||
{
|
||||
public bool? ImageSigned { get; init; }
|
||||
|
||||
public string? SbomReferrer { get; init; }
|
||||
|
||||
public RuntimeAttestation? Attestation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeAttestation
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public bool? Verified { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeDelta
|
||||
{
|
||||
public string? BaselineImageDigest { get; init; }
|
||||
|
||||
public IReadOnlyList<string> ChangedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<RuntimeNewBinary> NewBinaries { get; init; } = Array.Empty<RuntimeNewBinary>();
|
||||
}
|
||||
|
||||
public sealed record class RuntimeNewBinary
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
public sealed record class RuntimeEvidence
|
||||
{
|
||||
public required string Signal { get; init; }
|
||||
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,173 +1,173 @@
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Centralises schema identifiers and version negotiation rules for Zastava contracts.
|
||||
/// </summary>
|
||||
public static class ZastavaContractVersions
|
||||
{
|
||||
/// <summary>
|
||||
/// Current local runtime event contract (major version 1).
|
||||
/// </summary>
|
||||
public static ContractVersion RuntimeEvent { get; } = new("zastava.runtime.event", new Version(1, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Current local admission decision contract (major version 1).
|
||||
/// </summary>
|
||||
public static ContractVersion AdmissionDecision { get; } = new("zastava.admission.decision", new Version(1, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided schema string is supported for runtime events.
|
||||
/// </summary>
|
||||
public static bool IsRuntimeEventSupported(string schemaVersion)
|
||||
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(RuntimeEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided schema string is supported for admission decisions.
|
||||
/// </summary>
|
||||
public static bool IsAdmissionDecisionSupported(string schemaVersion)
|
||||
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(AdmissionDecision);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the newest runtime event contract shared between the local implementation and a remote peer.
|
||||
/// </summary>
|
||||
public static ContractVersion NegotiateRuntimeEvent(IEnumerable<string> offeredSchemaVersions)
|
||||
=> Negotiate(RuntimeEvent, offeredSchemaVersions);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the newest admission decision contract shared between the local implementation and a remote peer.
|
||||
/// </summary>
|
||||
public static ContractVersion NegotiateAdmissionDecision(IEnumerable<string> offeredSchemaVersions)
|
||||
=> Negotiate(AdmissionDecision, offeredSchemaVersions);
|
||||
|
||||
private static ContractVersion Negotiate(ContractVersion local, IEnumerable<string> offered)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(offered);
|
||||
|
||||
ContractVersion? best = null;
|
||||
foreach (var entry in offered)
|
||||
{
|
||||
if (!ContractVersion.TryParse(entry, out var candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidate.Schema.Equals(local.Schema, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Version.Major != local.Version.Major)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Version > local.Version)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || candidate.Version > best.Value.Version)
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? local;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema + semantic version pairing in canonical form.
|
||||
/// </summary>
|
||||
public readonly record struct ContractVersion
|
||||
{
|
||||
public ContractVersion(string schema, Version version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schema))
|
||||
{
|
||||
throw new ArgumentException("Schema cannot be null or whitespace.", nameof(schema));
|
||||
}
|
||||
|
||||
Schema = schema.Trim();
|
||||
Version = new Version(Math.Max(version.Major, 0), Math.Max(version.Minor, 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema identifier (e.g. <c>zastava.runtime.event</c>).
|
||||
/// </summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Major/minor version recognised by the implementation.
|
||||
/// </summary>
|
||||
public Version Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string representation (schema@vMajor.Minor).
|
||||
/// </summary>
|
||||
namespace StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Centralises schema identifiers and version negotiation rules for Zastava contracts.
|
||||
/// </summary>
|
||||
public static class ZastavaContractVersions
|
||||
{
|
||||
/// <summary>
|
||||
/// Current local runtime event contract (major version 1).
|
||||
/// </summary>
|
||||
public static ContractVersion RuntimeEvent { get; } = new("zastava.runtime.event", new Version(1, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Current local admission decision contract (major version 1).
|
||||
/// </summary>
|
||||
public static ContractVersion AdmissionDecision { get; } = new("zastava.admission.decision", new Version(1, 0));
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided schema string is supported for runtime events.
|
||||
/// </summary>
|
||||
public static bool IsRuntimeEventSupported(string schemaVersion)
|
||||
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(RuntimeEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the provided schema string is supported for admission decisions.
|
||||
/// </summary>
|
||||
public static bool IsAdmissionDecisionSupported(string schemaVersion)
|
||||
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(AdmissionDecision);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the newest runtime event contract shared between the local implementation and a remote peer.
|
||||
/// </summary>
|
||||
public static ContractVersion NegotiateRuntimeEvent(IEnumerable<string> offeredSchemaVersions)
|
||||
=> Negotiate(RuntimeEvent, offeredSchemaVersions);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the newest admission decision contract shared between the local implementation and a remote peer.
|
||||
/// </summary>
|
||||
public static ContractVersion NegotiateAdmissionDecision(IEnumerable<string> offeredSchemaVersions)
|
||||
=> Negotiate(AdmissionDecision, offeredSchemaVersions);
|
||||
|
||||
private static ContractVersion Negotiate(ContractVersion local, IEnumerable<string> offered)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(offered);
|
||||
|
||||
ContractVersion? best = null;
|
||||
foreach (var entry in offered)
|
||||
{
|
||||
if (!ContractVersion.TryParse(entry, out var candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidate.Schema.Equals(local.Schema, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Version.Major != local.Version.Major)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Version > local.Version)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || candidate.Version > best.Value.Version)
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? local;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema + semantic version pairing in canonical form.
|
||||
/// </summary>
|
||||
public readonly record struct ContractVersion
|
||||
{
|
||||
public ContractVersion(string schema, Version version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schema))
|
||||
{
|
||||
throw new ArgumentException("Schema cannot be null or whitespace.", nameof(schema));
|
||||
}
|
||||
|
||||
Schema = schema.Trim();
|
||||
Version = new Version(Math.Max(version.Major, 0), Math.Max(version.Minor, 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema identifier (e.g. <c>zastava.runtime.event</c>).
|
||||
/// </summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Major/minor version recognised by the implementation.
|
||||
/// </summary>
|
||||
public Version Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string representation (schema@vMajor.Minor).
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
=> $"{Schema}@v{Version.ToString(2)}";
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a remote contract is compatible with the local definition.
|
||||
/// </summary>
|
||||
public bool IsCompatibleWith(ContractVersion local)
|
||||
{
|
||||
if (!Schema.Equals(local.Schema, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Version.Major != local.Version.Major)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Version <= local.Version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a schema string in canonical format.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out ContractVersion contract)
|
||||
{
|
||||
contract = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var separator = trimmed.IndexOf('@');
|
||||
if (separator < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var schema = trimmed[..separator];
|
||||
if (!schema.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionToken = trimmed[(separator + 1)..];
|
||||
if (versionToken.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (versionToken[0] is 'v' or 'V')
|
||||
{
|
||||
versionToken = versionToken[1..];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(versionToken, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var canonical = new Version(Math.Max(parsed.Major, 0), Math.Max(parsed.Minor, 0));
|
||||
contract = new ContractVersion(schema, canonical);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a remote contract is compatible with the local definition.
|
||||
/// </summary>
|
||||
public bool IsCompatibleWith(ContractVersion local)
|
||||
{
|
||||
if (!Schema.Equals(local.Schema, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Version.Major != local.Version.Major)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Version <= local.Version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a schema string in canonical format.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out ContractVersion contract)
|
||||
{
|
||||
contract = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var separator = trimmed.IndexOf('@');
|
||||
if (separator < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var schema = trimmed[..separator];
|
||||
if (!schema.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionToken = trimmed[(separator + 1)..];
|
||||
if (versionToken.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (versionToken[0] is 'v' or 'V')
|
||||
{
|
||||
versionToken = versionToken[1..];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(versionToken, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var canonical = new Version(Math.Max(parsed.Major, 0), Math.Max(parsed.Minor, 0));
|
||||
contract = new ContractVersion(schema, canonical);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class ZastavaServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddZastavaRuntimeCore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string componentName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (string.IsNullOrWhiteSpace(componentName))
|
||||
{
|
||||
throw new ArgumentException("Component name is required.", nameof(componentName));
|
||||
}
|
||||
|
||||
services.AddOptions<ZastavaRuntimeOptions>()
|
||||
.Bind(configuration.GetSection(ZastavaRuntimeOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(static options => !string.IsNullOrWhiteSpace(options.Tenant), "Tenant is required.")
|
||||
.Validate(static options => !string.IsNullOrWhiteSpace(options.Environment), "Environment is required.")
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Component))
|
||||
{
|
||||
options.Component = componentName;
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<LoggerFactoryOptions>, ZastavaLoggerFactoryOptionsConfigurator>());
|
||||
services.TryAddSingleton<IZastavaLogScopeBuilder, ZastavaLogScopeBuilder>();
|
||||
services.TryAddSingleton<IZastavaRuntimeMetrics, ZastavaRuntimeMetrics>();
|
||||
ConfigureAuthorityServices(services, configuration);
|
||||
services.TryAddSingleton<IZastavaAuthorityTokenProvider, ZastavaAuthorityTokenProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureAuthorityServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var authoritySection = configuration.GetSection($"{ZastavaRuntimeOptions.SectionName}:authority");
|
||||
var authorityOptions = new ZastavaAuthorityOptions();
|
||||
authoritySection.Bind(authorityOptions);
|
||||
|
||||
services.AddStellaOpsAuthClient(options =>
|
||||
{
|
||||
options.Authority = authorityOptions.Issuer.ToString();
|
||||
options.ClientId = authorityOptions.ClientId;
|
||||
options.ClientSecret = authorityOptions.ClientSecret;
|
||||
options.AllowOfflineCacheFallback = authorityOptions.AllowStaticTokenFallback;
|
||||
options.ExpirationSkew = TimeSpan.FromSeconds(Math.Clamp(authorityOptions.RefreshSkewSeconds, 0, 300));
|
||||
|
||||
options.DefaultScopes.Clear();
|
||||
var normalized = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (authorityOptions.Audience is not null)
|
||||
{
|
||||
foreach (var audience in authorityOptions.Audience)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add($"aud:{audience.Trim().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
if (authorityOptions.Scopes is not null)
|
||||
{
|
||||
foreach (var scope in authorityOptions.Scopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
normalized.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scope in normalized)
|
||||
{
|
||||
options.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class ZastavaServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddZastavaRuntimeCore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string componentName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (string.IsNullOrWhiteSpace(componentName))
|
||||
{
|
||||
throw new ArgumentException("Component name is required.", nameof(componentName));
|
||||
}
|
||||
|
||||
services.AddOptions<ZastavaRuntimeOptions>()
|
||||
.Bind(configuration.GetSection(ZastavaRuntimeOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(static options => !string.IsNullOrWhiteSpace(options.Tenant), "Tenant is required.")
|
||||
.Validate(static options => !string.IsNullOrWhiteSpace(options.Environment), "Environment is required.")
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Component))
|
||||
{
|
||||
options.Component = componentName;
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<LoggerFactoryOptions>, ZastavaLoggerFactoryOptionsConfigurator>());
|
||||
services.TryAddSingleton<IZastavaLogScopeBuilder, ZastavaLogScopeBuilder>();
|
||||
services.TryAddSingleton<IZastavaRuntimeMetrics, ZastavaRuntimeMetrics>();
|
||||
ConfigureAuthorityServices(services, configuration);
|
||||
services.TryAddSingleton<IZastavaAuthorityTokenProvider, ZastavaAuthorityTokenProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureAuthorityServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var authoritySection = configuration.GetSection($"{ZastavaRuntimeOptions.SectionName}:authority");
|
||||
var authorityOptions = new ZastavaAuthorityOptions();
|
||||
authoritySection.Bind(authorityOptions);
|
||||
|
||||
services.AddStellaOpsAuthClient(options =>
|
||||
{
|
||||
options.Authority = authorityOptions.Issuer.ToString();
|
||||
options.ClientId = authorityOptions.ClientId;
|
||||
options.ClientSecret = authorityOptions.ClientSecret;
|
||||
options.AllowOfflineCacheFallback = authorityOptions.AllowStaticTokenFallback;
|
||||
options.ExpirationSkew = TimeSpan.FromSeconds(Math.Clamp(authorityOptions.RefreshSkewSeconds, 0, 300));
|
||||
|
||||
options.DefaultScopes.Clear();
|
||||
var normalized = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (authorityOptions.Audience is not null)
|
||||
{
|
||||
foreach (var audience in authorityOptions.Audience)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add($"aud:{audience.Trim().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
if (authorityOptions.Scopes is not null)
|
||||
{
|
||||
foreach (var scope in authorityOptions.Scopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
normalized.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scope in normalized)
|
||||
{
|
||||
options.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
public interface IZastavaLogScopeBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a deterministic logging scope containing tenant/component metadata.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, object?> BuildScope(
|
||||
string? correlationId = null,
|
||||
string? node = null,
|
||||
string? workload = null,
|
||||
string? eventId = null,
|
||||
IReadOnlyDictionary<string, string>? additional = null);
|
||||
}
|
||||
|
||||
internal sealed class ZastavaLogScopeBuilder : IZastavaLogScopeBuilder
|
||||
{
|
||||
private readonly ZastavaRuntimeOptions options;
|
||||
private readonly IReadOnlyDictionary<string, string> staticScope;
|
||||
|
||||
public ZastavaLogScopeBuilder(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.options = options.Value;
|
||||
staticScope = (this.options.Logging.StaticScope ?? new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, object?> BuildScope(
|
||||
string? correlationId = null,
|
||||
string? node = null,
|
||||
string? workload = null,
|
||||
string? eventId = null,
|
||||
IReadOnlyDictionary<string, string>? additional = null)
|
||||
{
|
||||
var scope = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant"] = options.Tenant,
|
||||
["component"] = options.Component,
|
||||
["environment"] = options.Environment
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Deployment))
|
||||
{
|
||||
scope["deployment"] = options.Deployment;
|
||||
}
|
||||
|
||||
foreach (var pair in staticScope)
|
||||
{
|
||||
scope[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
scope["correlationId"] = correlationId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node))
|
||||
{
|
||||
scope["node"] = node;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workload))
|
||||
{
|
||||
scope["workload"] = workload;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
scope["eventId"] = eventId;
|
||||
}
|
||||
|
||||
if (additional is not null)
|
||||
{
|
||||
foreach (var pair in additional)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
scope[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scope.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
public interface IZastavaLogScopeBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a deterministic logging scope containing tenant/component metadata.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, object?> BuildScope(
|
||||
string? correlationId = null,
|
||||
string? node = null,
|
||||
string? workload = null,
|
||||
string? eventId = null,
|
||||
IReadOnlyDictionary<string, string>? additional = null);
|
||||
}
|
||||
|
||||
internal sealed class ZastavaLogScopeBuilder : IZastavaLogScopeBuilder
|
||||
{
|
||||
private readonly ZastavaRuntimeOptions options;
|
||||
private readonly IReadOnlyDictionary<string, string> staticScope;
|
||||
|
||||
public ZastavaLogScopeBuilder(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.options = options.Value;
|
||||
staticScope = (this.options.Logging.StaticScope ?? new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, object?> BuildScope(
|
||||
string? correlationId = null,
|
||||
string? node = null,
|
||||
string? workload = null,
|
||||
string? eventId = null,
|
||||
IReadOnlyDictionary<string, string>? additional = null)
|
||||
{
|
||||
var scope = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant"] = options.Tenant,
|
||||
["component"] = options.Component,
|
||||
["environment"] = options.Environment
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Deployment))
|
||||
{
|
||||
scope["deployment"] = options.Deployment;
|
||||
}
|
||||
|
||||
foreach (var pair in staticScope)
|
||||
{
|
||||
scope[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
scope["correlationId"] = correlationId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node))
|
||||
{
|
||||
scope["node"] = node;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workload))
|
||||
{
|
||||
scope["workload"] = workload;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
scope["eventId"] = eventId;
|
||||
}
|
||||
|
||||
if (additional is not null)
|
||||
{
|
||||
foreach (var pair in additional)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
scope[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scope.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
internal sealed class ZastavaLoggerFactoryOptionsConfigurator : IConfigureOptions<LoggerFactoryOptions>
|
||||
{
|
||||
private readonly IOptions<ZastavaRuntimeOptions> options;
|
||||
|
||||
public ZastavaLoggerFactoryOptionsConfigurator(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public void Configure(LoggerFactoryOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var runtimeOptions = this.options.Value;
|
||||
if (runtimeOptions.Logging.IncludeActivityTracking)
|
||||
{
|
||||
options.ActivityTrackingOptions |= ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId | ActivityTrackingOptions.ParentId;
|
||||
}
|
||||
else if (runtimeOptions.Logging.IncludeScopes)
|
||||
{
|
||||
options.ActivityTrackingOptions |= ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId;
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
internal sealed class ZastavaLoggerFactoryOptionsConfigurator : IConfigureOptions<LoggerFactoryOptions>
|
||||
{
|
||||
private readonly IOptions<ZastavaRuntimeOptions> options;
|
||||
|
||||
public ZastavaLoggerFactoryOptionsConfigurator(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public void Configure(LoggerFactoryOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var runtimeOptions = this.options.Value;
|
||||
if (runtimeOptions.Logging.IncludeActivityTracking)
|
||||
{
|
||||
options.ActivityTrackingOptions |= ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId | ActivityTrackingOptions.ParentId;
|
||||
}
|
||||
else if (runtimeOptions.Logging.IncludeScopes)
|
||||
{
|
||||
options.ActivityTrackingOptions |= ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
public interface IZastavaRuntimeMetrics : IDisposable
|
||||
{
|
||||
Meter Meter { get; }
|
||||
Counter<long> RuntimeEvents { get; }
|
||||
Counter<long> AdmissionDecisions { get; }
|
||||
Histogram<double> BackendLatencyMs { get; }
|
||||
IReadOnlyList<KeyValuePair<string, object?>> DefaultTags { get; }
|
||||
}
|
||||
|
||||
internal sealed class ZastavaRuntimeMetrics : IZastavaRuntimeMetrics
|
||||
{
|
||||
private readonly Meter meter;
|
||||
private readonly IReadOnlyList<KeyValuePair<string, object?>> defaultTags;
|
||||
private readonly bool enabled;
|
||||
|
||||
public ZastavaRuntimeMetrics(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var runtimeOptions = options.Value;
|
||||
var metrics = runtimeOptions.Metrics ?? new ZastavaRuntimeMetricsOptions();
|
||||
enabled = metrics.Enabled;
|
||||
|
||||
meter = new Meter(metrics.MeterName, metrics.MeterVersion);
|
||||
|
||||
RuntimeEvents = meter.CreateCounter<long>("zastava.runtime.events.total", unit: "1", description: "Total runtime events emitted by observers.");
|
||||
AdmissionDecisions = meter.CreateCounter<long>("zastava.admission.decisions.total", unit: "1", description: "Total admission decisions returned by the webhook.");
|
||||
BackendLatencyMs = meter.CreateHistogram<double>("zastava.runtime.backend.latency.ms", unit: "ms", description: "Round-trip latency to Scanner backend APIs.");
|
||||
|
||||
var baseline = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("tenant", runtimeOptions.Tenant),
|
||||
new("component", runtimeOptions.Component),
|
||||
new("environment", runtimeOptions.Environment)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtimeOptions.Deployment))
|
||||
{
|
||||
baseline.Add(new("deployment", runtimeOptions.Deployment));
|
||||
}
|
||||
|
||||
if (metrics.CommonTags is not null)
|
||||
{
|
||||
foreach (var pair in metrics.CommonTags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
baseline.Add(new(pair.Key, pair.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultTags = baseline.ToImmutableArray();
|
||||
}
|
||||
|
||||
public Meter Meter => meter;
|
||||
|
||||
public Counter<long> RuntimeEvents { get; }
|
||||
|
||||
public Counter<long> AdmissionDecisions { get; }
|
||||
|
||||
public Histogram<double> BackendLatencyMs { get; }
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> DefaultTags => defaultTags;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
public interface IZastavaRuntimeMetrics : IDisposable
|
||||
{
|
||||
Meter Meter { get; }
|
||||
Counter<long> RuntimeEvents { get; }
|
||||
Counter<long> AdmissionDecisions { get; }
|
||||
Histogram<double> BackendLatencyMs { get; }
|
||||
IReadOnlyList<KeyValuePair<string, object?>> DefaultTags { get; }
|
||||
}
|
||||
|
||||
internal sealed class ZastavaRuntimeMetrics : IZastavaRuntimeMetrics
|
||||
{
|
||||
private readonly Meter meter;
|
||||
private readonly IReadOnlyList<KeyValuePair<string, object?>> defaultTags;
|
||||
private readonly bool enabled;
|
||||
|
||||
public ZastavaRuntimeMetrics(IOptions<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var runtimeOptions = options.Value;
|
||||
var metrics = runtimeOptions.Metrics ?? new ZastavaRuntimeMetricsOptions();
|
||||
enabled = metrics.Enabled;
|
||||
|
||||
meter = new Meter(metrics.MeterName, metrics.MeterVersion);
|
||||
|
||||
RuntimeEvents = meter.CreateCounter<long>("zastava.runtime.events.total", unit: "1", description: "Total runtime events emitted by observers.");
|
||||
AdmissionDecisions = meter.CreateCounter<long>("zastava.admission.decisions.total", unit: "1", description: "Total admission decisions returned by the webhook.");
|
||||
BackendLatencyMs = meter.CreateHistogram<double>("zastava.runtime.backend.latency.ms", unit: "ms", description: "Round-trip latency to Scanner backend APIs.");
|
||||
|
||||
var baseline = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("tenant", runtimeOptions.Tenant),
|
||||
new("component", runtimeOptions.Component),
|
||||
new("environment", runtimeOptions.Environment)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtimeOptions.Deployment))
|
||||
{
|
||||
baseline.Add(new("deployment", runtimeOptions.Deployment));
|
||||
}
|
||||
|
||||
if (metrics.CommonTags is not null)
|
||||
{
|
||||
foreach (var pair in metrics.CommonTags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
baseline.Add(new(pair.Key, pair.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultTags = baseline.ToImmutableArray();
|
||||
}
|
||||
|
||||
public Meter Meter => meter;
|
||||
|
||||
public Counter<long> RuntimeEvents { get; }
|
||||
|
||||
public Counter<long> AdmissionDecisions { get; }
|
||||
|
||||
public Histogram<double> BackendLatencyMs { get; }
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> DefaultTags => defaultTags;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Diagnostics;
|
||||
global using System.Diagnostics.Metrics;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Text.Json.Serialization.Metadata;
|
||||
global using System.Globalization;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Diagnostics;
|
||||
global using System.Diagnostics.Metrics;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Text.Json.Serialization.Metadata;
|
||||
global using System.Globalization;
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic multihashes for runtime and admission payloads.
|
||||
/// </summary>
|
||||
public static class ZastavaHashing
|
||||
{
|
||||
public const string DefaultAlgorithm = "sha256";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the payload using canonical options and computes a multihash string.
|
||||
/// </summary>
|
||||
public static string ComputeMultihash<T>(T value, string? algorithm = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var bytes = ZastavaCanonicalJsonSerializer.SerializeToUtf8Bytes(value);
|
||||
return ComputeMultihash(bytes, algorithm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a multihash string from the provided payload.
|
||||
/// </summary>
|
||||
public static string ComputeMultihash(ReadOnlySpan<byte> payload, string? algorithm = null)
|
||||
{
|
||||
var normalized = NormalizeAlgorithm(algorithm);
|
||||
var digest = normalized switch
|
||||
{
|
||||
"sha256" => SHA256.HashData(payload),
|
||||
"sha512" => SHA512.HashData(payload),
|
||||
_ => throw new NotSupportedException($"Hash algorithm '{normalized}' is not supported.")
|
||||
};
|
||||
|
||||
return $"{normalized}-{ToBase64Url(digest)}";
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string? algorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
return DefaultAlgorithm;
|
||||
}
|
||||
|
||||
var normalized = algorithm.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"sha-256" or "sha256" => "sha256",
|
||||
"sha-512" or "sha512" => "sha512",
|
||||
_ => normalized
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToBase64Url(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
}
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic multihashes for runtime and admission payloads.
|
||||
/// </summary>
|
||||
public static class ZastavaHashing
|
||||
{
|
||||
public const string DefaultAlgorithm = "sha256";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the payload using canonical options and computes a multihash string.
|
||||
/// </summary>
|
||||
public static string ComputeMultihash<T>(T value, string? algorithm = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var bytes = ZastavaCanonicalJsonSerializer.SerializeToUtf8Bytes(value);
|
||||
return ComputeMultihash(bytes, algorithm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a multihash string from the provided payload.
|
||||
/// </summary>
|
||||
public static string ComputeMultihash(ReadOnlySpan<byte> payload, string? algorithm = null)
|
||||
{
|
||||
var normalized = NormalizeAlgorithm(algorithm);
|
||||
var digest = normalized switch
|
||||
{
|
||||
"sha256" => SHA256.HashData(payload),
|
||||
"sha512" => SHA512.HashData(payload),
|
||||
_ => throw new NotSupportedException($"Hash algorithm '{normalized}' is not supported.")
|
||||
};
|
||||
|
||||
return $"{normalized}-{ToBase64Url(digest)}";
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string? algorithm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
return DefaultAlgorithm;
|
||||
}
|
||||
|
||||
var normalized = algorithm.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"sha-256" or "sha256" => "sha256",
|
||||
"sha-512" or "sha512" => "sha512",
|
||||
_ => normalized
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToBase64Url(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Core.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Core.Tests")]
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
public interface IZastavaAuthorityTokenProvider
|
||||
{
|
||||
ValueTask<ZastavaOperationalToken> GetAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask InvalidateAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
public interface IZastavaAuthorityTokenProvider
|
||||
{
|
||||
ValueTask<ZastavaOperationalToken> GetAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask InvalidateAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,314 +1,314 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
internal sealed class ZastavaAuthorityTokenProvider : IZastavaAuthorityTokenProvider
|
||||
{
|
||||
private readonly IStellaOpsTokenClient tokenClient;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor;
|
||||
private readonly IZastavaLogScopeBuilder scopeBuilder;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<ZastavaAuthorityTokenProvider> logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> cache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
|
||||
private readonly object guardrailLock = new();
|
||||
private bool guardrailsLogged;
|
||||
private ZastavaOperationalToken? staticFallbackToken;
|
||||
|
||||
public ZastavaAuthorityTokenProvider(
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
|
||||
IZastavaLogScopeBuilder scopeBuilder,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<ZastavaAuthorityTokenProvider>? logger = null)
|
||||
{
|
||||
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.scopeBuilder = scopeBuilder ?? throw new ArgumentNullException(nameof(scopeBuilder));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? NullLogger<ZastavaAuthorityTokenProvider>.Instance;
|
||||
}
|
||||
|
||||
public async ValueTask<ZastavaOperationalToken> GetAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
|
||||
var options = optionsMonitor.CurrentValue.Authority;
|
||||
EnsureGuardrails(options);
|
||||
|
||||
if (options.AllowStaticTokenFallback && TryGetStaticToken(options) is { } staticToken)
|
||||
{
|
||||
return staticToken;
|
||||
}
|
||||
|
||||
var normalizedAudience = NormalizeAudience(audience);
|
||||
var normalizedScopes = BuildScopes(options, normalizedAudience, additionalScopes);
|
||||
var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes);
|
||||
var refreshSkew = GetRefreshSkew(options);
|
||||
|
||||
if (cache.TryGetValue(cacheKey, out var cached) && !cached.Token.IsExpired(timeProvider, refreshSkew))
|
||||
{
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
var mutex = locks.GetOrAdd(cacheKey, static _ => new SemaphoreSlim(1, 1));
|
||||
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (cache.TryGetValue(cacheKey, out cached) && !cached.Token.IsExpired(timeProvider, refreshSkew))
|
||||
{
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
var scopeString = string.Join(' ', normalizedScopes);
|
||||
var tokenResult = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
ValidateToken(tokenResult, options, normalizedAudience);
|
||||
|
||||
var token = ZastavaOperationalToken.FromResult(
|
||||
tokenResult.AccessToken,
|
||||
tokenResult.TokenType,
|
||||
tokenResult.ExpiresAtUtc,
|
||||
tokenResult.Scopes);
|
||||
|
||||
cache[cacheKey] = new CacheEntry(token);
|
||||
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
correlationId: null,
|
||||
node: null,
|
||||
workload: null,
|
||||
eventId: "authority.token.issue",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = normalizedAudience,
|
||||
["expiresAt"] = token.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture) ?? "static",
|
||||
["scopes"] = scopeString
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Issued runtime OpTok for {Audience} (scopes: {Scopes}).", normalizedAudience, scopeString);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
catch (Exception ex) when (options.AllowStaticTokenFallback && TryGetStaticToken(options) is { } fallback)
|
||||
{
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
eventId: "authority.token.fallback",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = audience
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogWarning(ex, "Authority token acquisition failed; using static fallback token.");
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InvalidateAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
|
||||
var normalizedAudience = NormalizeAudience(audience);
|
||||
var normalizedScopes = BuildScopes(optionsMonitor.CurrentValue.Authority, normalizedAudience, additionalScopes);
|
||||
var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes);
|
||||
|
||||
cache.TryRemove(cacheKey, out _);
|
||||
if (locks.TryRemove(cacheKey, out var mutex))
|
||||
{
|
||||
mutex.Dispose();
|
||||
}
|
||||
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
eventId: "authority.token.invalidate",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = normalizedAudience,
|
||||
["cacheKey"] = cacheKey
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Invalidated runtime OpTok cache entry.");
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsureGuardrails(ZastavaAuthorityOptions options)
|
||||
{
|
||||
if (guardrailsLogged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (guardrailLock)
|
||||
{
|
||||
if (guardrailsLogged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = scopeBuilder.BuildScope(eventId: "authority.guardrails");
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
if (!options.RequireMutualTls)
|
||||
{
|
||||
logger.LogWarning("Mutual TLS requirement disabled for Authority token acquisition. This should only be used in controlled test environments.");
|
||||
}
|
||||
|
||||
if (!options.RequireDpop)
|
||||
{
|
||||
logger.LogWarning("DPoP requirement disabled for runtime plane. Tokens will be issued without proof-of-possession.");
|
||||
}
|
||||
|
||||
if (options.AllowStaticTokenFallback)
|
||||
{
|
||||
logger.LogWarning("Static Authority token fallback enabled. Ensure bootstrap tokens are rotated frequently.");
|
||||
}
|
||||
}
|
||||
|
||||
guardrailsLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ZastavaOperationalToken? TryGetStaticToken(ZastavaAuthorityOptions options)
|
||||
{
|
||||
if (!options.AllowStaticTokenFallback)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.StaticTokenValue is null && options.StaticTokenPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (staticFallbackToken is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
lock (guardrailLock)
|
||||
{
|
||||
if (staticFallbackToken is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var tokenValue = options.StaticTokenValue;
|
||||
if (string.IsNullOrWhiteSpace(tokenValue) && !string.IsNullOrWhiteSpace(options.StaticTokenPath))
|
||||
{
|
||||
if (!File.Exists(options.StaticTokenPath))
|
||||
{
|
||||
throw new FileNotFoundException("Static Authority token file not found.", options.StaticTokenPath);
|
||||
}
|
||||
|
||||
tokenValue = File.ReadAllText(options.StaticTokenPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenValue))
|
||||
{
|
||||
throw new InvalidOperationException("Static Authority token fallback is enabled but no token value/path is configured.");
|
||||
}
|
||||
|
||||
staticFallbackToken = ZastavaOperationalToken.FromResult(
|
||||
tokenValue.Trim(),
|
||||
tokenType: "Bearer",
|
||||
expiresAtUtc: null,
|
||||
scopes: Array.Empty<string>());
|
||||
|
||||
return staticFallbackToken;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateToken(StellaOpsTokenResult tokenResult, ZastavaAuthorityOptions options, string normalizedAudience)
|
||||
{
|
||||
if (options.RequireDpop && !string.Equals(tokenResult.TokenType, "DPoP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Authority returned a token without DPoP token type while RequireDpop is enabled.");
|
||||
}
|
||||
|
||||
if (tokenResult.Scopes is not null)
|
||||
{
|
||||
var audienceScope = $"aud:{normalizedAudience}";
|
||||
if (!tokenResult.Scopes.Contains(audienceScope, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority token missing required audience scope '{audienceScope}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeAudience(string audience)
|
||||
=> audience.Trim().ToLowerInvariant();
|
||||
|
||||
private static IReadOnlyList<string> BuildScopes(
|
||||
ZastavaAuthorityOptions options,
|
||||
string normalizedAudience,
|
||||
IEnumerable<string>? additionalScopes)
|
||||
{
|
||||
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
$"aud:{normalizedAudience}"
|
||||
};
|
||||
|
||||
if (options.Scopes is not null)
|
||||
{
|
||||
foreach (var scope in options.Scopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalScopes is not null)
|
||||
{
|
||||
foreach (var scope in additionalScopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopeSet.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string audience, IReadOnlyList<string> scopes)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{audience}|{string.Join(' ', scopes)}")));
|
||||
|
||||
private static TimeSpan GetRefreshSkew(ZastavaAuthorityOptions options)
|
||||
{
|
||||
var seconds = Math.Clamp(options.RefreshSkewSeconds, 0, 3600);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry(ZastavaOperationalToken Token);
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
internal sealed class ZastavaAuthorityTokenProvider : IZastavaAuthorityTokenProvider
|
||||
{
|
||||
private readonly IStellaOpsTokenClient tokenClient;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor;
|
||||
private readonly IZastavaLogScopeBuilder scopeBuilder;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<ZastavaAuthorityTokenProvider> logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> cache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
|
||||
private readonly object guardrailLock = new();
|
||||
private bool guardrailsLogged;
|
||||
private ZastavaOperationalToken? staticFallbackToken;
|
||||
|
||||
public ZastavaAuthorityTokenProvider(
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
|
||||
IZastavaLogScopeBuilder scopeBuilder,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<ZastavaAuthorityTokenProvider>? logger = null)
|
||||
{
|
||||
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.scopeBuilder = scopeBuilder ?? throw new ArgumentNullException(nameof(scopeBuilder));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger ?? NullLogger<ZastavaAuthorityTokenProvider>.Instance;
|
||||
}
|
||||
|
||||
public async ValueTask<ZastavaOperationalToken> GetAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
|
||||
var options = optionsMonitor.CurrentValue.Authority;
|
||||
EnsureGuardrails(options);
|
||||
|
||||
if (options.AllowStaticTokenFallback && TryGetStaticToken(options) is { } staticToken)
|
||||
{
|
||||
return staticToken;
|
||||
}
|
||||
|
||||
var normalizedAudience = NormalizeAudience(audience);
|
||||
var normalizedScopes = BuildScopes(options, normalizedAudience, additionalScopes);
|
||||
var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes);
|
||||
var refreshSkew = GetRefreshSkew(options);
|
||||
|
||||
if (cache.TryGetValue(cacheKey, out var cached) && !cached.Token.IsExpired(timeProvider, refreshSkew))
|
||||
{
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
var mutex = locks.GetOrAdd(cacheKey, static _ => new SemaphoreSlim(1, 1));
|
||||
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (cache.TryGetValue(cacheKey, out cached) && !cached.Token.IsExpired(timeProvider, refreshSkew))
|
||||
{
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
var scopeString = string.Join(' ', normalizedScopes);
|
||||
var tokenResult = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
|
||||
ValidateToken(tokenResult, options, normalizedAudience);
|
||||
|
||||
var token = ZastavaOperationalToken.FromResult(
|
||||
tokenResult.AccessToken,
|
||||
tokenResult.TokenType,
|
||||
tokenResult.ExpiresAtUtc,
|
||||
tokenResult.Scopes);
|
||||
|
||||
cache[cacheKey] = new CacheEntry(token);
|
||||
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
correlationId: null,
|
||||
node: null,
|
||||
workload: null,
|
||||
eventId: "authority.token.issue",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = normalizedAudience,
|
||||
["expiresAt"] = token.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture) ?? "static",
|
||||
["scopes"] = scopeString
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Issued runtime OpTok for {Audience} (scopes: {Scopes}).", normalizedAudience, scopeString);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
catch (Exception ex) when (options.AllowStaticTokenFallback && TryGetStaticToken(options) is { } fallback)
|
||||
{
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
eventId: "authority.token.fallback",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = audience
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogWarning(ex, "Authority token acquisition failed; using static fallback token.");
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InvalidateAsync(
|
||||
string audience,
|
||||
IEnumerable<string>? additionalScopes = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
|
||||
var normalizedAudience = NormalizeAudience(audience);
|
||||
var normalizedScopes = BuildScopes(optionsMonitor.CurrentValue.Authority, normalizedAudience, additionalScopes);
|
||||
var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes);
|
||||
|
||||
cache.TryRemove(cacheKey, out _);
|
||||
if (locks.TryRemove(cacheKey, out var mutex))
|
||||
{
|
||||
mutex.Dispose();
|
||||
}
|
||||
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
eventId: "authority.token.invalidate",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["audience"] = normalizedAudience,
|
||||
["cacheKey"] = cacheKey
|
||||
});
|
||||
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
logger.LogInformation("Invalidated runtime OpTok cache entry.");
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsureGuardrails(ZastavaAuthorityOptions options)
|
||||
{
|
||||
if (guardrailsLogged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (guardrailLock)
|
||||
{
|
||||
if (guardrailsLogged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = scopeBuilder.BuildScope(eventId: "authority.guardrails");
|
||||
using (logger.BeginScope(scope))
|
||||
{
|
||||
if (!options.RequireMutualTls)
|
||||
{
|
||||
logger.LogWarning("Mutual TLS requirement disabled for Authority token acquisition. This should only be used in controlled test environments.");
|
||||
}
|
||||
|
||||
if (!options.RequireDpop)
|
||||
{
|
||||
logger.LogWarning("DPoP requirement disabled for runtime plane. Tokens will be issued without proof-of-possession.");
|
||||
}
|
||||
|
||||
if (options.AllowStaticTokenFallback)
|
||||
{
|
||||
logger.LogWarning("Static Authority token fallback enabled. Ensure bootstrap tokens are rotated frequently.");
|
||||
}
|
||||
}
|
||||
|
||||
guardrailsLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ZastavaOperationalToken? TryGetStaticToken(ZastavaAuthorityOptions options)
|
||||
{
|
||||
if (!options.AllowStaticTokenFallback)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.StaticTokenValue is null && options.StaticTokenPath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (staticFallbackToken is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
lock (guardrailLock)
|
||||
{
|
||||
if (staticFallbackToken is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var tokenValue = options.StaticTokenValue;
|
||||
if (string.IsNullOrWhiteSpace(tokenValue) && !string.IsNullOrWhiteSpace(options.StaticTokenPath))
|
||||
{
|
||||
if (!File.Exists(options.StaticTokenPath))
|
||||
{
|
||||
throw new FileNotFoundException("Static Authority token file not found.", options.StaticTokenPath);
|
||||
}
|
||||
|
||||
tokenValue = File.ReadAllText(options.StaticTokenPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenValue))
|
||||
{
|
||||
throw new InvalidOperationException("Static Authority token fallback is enabled but no token value/path is configured.");
|
||||
}
|
||||
|
||||
staticFallbackToken = ZastavaOperationalToken.FromResult(
|
||||
tokenValue.Trim(),
|
||||
tokenType: "Bearer",
|
||||
expiresAtUtc: null,
|
||||
scopes: Array.Empty<string>());
|
||||
|
||||
return staticFallbackToken;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateToken(StellaOpsTokenResult tokenResult, ZastavaAuthorityOptions options, string normalizedAudience)
|
||||
{
|
||||
if (options.RequireDpop && !string.Equals(tokenResult.TokenType, "DPoP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Authority returned a token without DPoP token type while RequireDpop is enabled.");
|
||||
}
|
||||
|
||||
if (tokenResult.Scopes is not null)
|
||||
{
|
||||
var audienceScope = $"aud:{normalizedAudience}";
|
||||
if (!tokenResult.Scopes.Contains(audienceScope, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority token missing required audience scope '{audienceScope}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeAudience(string audience)
|
||||
=> audience.Trim().ToLowerInvariant();
|
||||
|
||||
private static IReadOnlyList<string> BuildScopes(
|
||||
ZastavaAuthorityOptions options,
|
||||
string normalizedAudience,
|
||||
IEnumerable<string>? additionalScopes)
|
||||
{
|
||||
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
$"aud:{normalizedAudience}"
|
||||
};
|
||||
|
||||
if (options.Scopes is not null)
|
||||
{
|
||||
foreach (var scope in options.Scopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalScopes is not null)
|
||||
{
|
||||
foreach (var scope in additionalScopes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
scopeSet.Add(scope.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopeSet.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string audience, IReadOnlyList<string> scopes)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{audience}|{string.Join(' ', scopes)}")));
|
||||
|
||||
private static TimeSpan GetRefreshSkew(ZastavaAuthorityOptions options)
|
||||
{
|
||||
var seconds = Math.Clamp(options.RefreshSkewSeconds, 0, 3600);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry(ZastavaOperationalToken Token);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
public readonly record struct ZastavaOperationalToken(
|
||||
string AccessToken,
|
||||
string TokenType,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
IReadOnlyList<string> Scopes)
|
||||
{
|
||||
public bool IsExpired(TimeProvider timeProvider, TimeSpan refreshSkew)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
if (ExpiresAtUtc is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return timeProvider.GetUtcNow() >= ExpiresAtUtc.Value - refreshSkew;
|
||||
}
|
||||
|
||||
public static ZastavaOperationalToken FromResult(
|
||||
string accessToken,
|
||||
string tokenType,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
IEnumerable<string> scopes)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(accessToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tokenType);
|
||||
|
||||
IReadOnlyList<string> normalized = scopes switch
|
||||
{
|
||||
null => Array.Empty<string>(),
|
||||
IReadOnlyList<string> readOnly => readOnly.Count == 0 ? Array.Empty<string>() : readOnly,
|
||||
ICollection<string> collection => NormalizeCollection(collection),
|
||||
_ => NormalizeEnumerable(scopes)
|
||||
};
|
||||
|
||||
return new ZastavaOperationalToken(
|
||||
accessToken,
|
||||
tokenType,
|
||||
expiresAtUtc,
|
||||
normalized);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeCollection(ICollection<string> collection)
|
||||
{
|
||||
if (collection.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (collection is IReadOnlyList<string> readOnly)
|
||||
{
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
var buffer = new string[collection.Count];
|
||||
collection.CopyTo(buffer, 0);
|
||||
return new ReadOnlyCollection<string>(buffer);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeEnumerable(IEnumerable<string> scopes)
|
||||
{
|
||||
var buffer = scopes.ToArray();
|
||||
return buffer.Length == 0 ? Array.Empty<string>() : new ReadOnlyCollection<string>(buffer);
|
||||
}
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Security;
|
||||
|
||||
public readonly record struct ZastavaOperationalToken(
|
||||
string AccessToken,
|
||||
string TokenType,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
IReadOnlyList<string> Scopes)
|
||||
{
|
||||
public bool IsExpired(TimeProvider timeProvider, TimeSpan refreshSkew)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
if (ExpiresAtUtc is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return timeProvider.GetUtcNow() >= ExpiresAtUtc.Value - refreshSkew;
|
||||
}
|
||||
|
||||
public static ZastavaOperationalToken FromResult(
|
||||
string accessToken,
|
||||
string tokenType,
|
||||
DateTimeOffset? expiresAtUtc,
|
||||
IEnumerable<string> scopes)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(accessToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tokenType);
|
||||
|
||||
IReadOnlyList<string> normalized = scopes switch
|
||||
{
|
||||
null => Array.Empty<string>(),
|
||||
IReadOnlyList<string> readOnly => readOnly.Count == 0 ? Array.Empty<string>() : readOnly,
|
||||
ICollection<string> collection => NormalizeCollection(collection),
|
||||
_ => NormalizeEnumerable(scopes)
|
||||
};
|
||||
|
||||
return new ZastavaOperationalToken(
|
||||
accessToken,
|
||||
tokenType,
|
||||
expiresAtUtc,
|
||||
normalized);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeCollection(ICollection<string> collection)
|
||||
{
|
||||
if (collection.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (collection is IReadOnlyList<string> readOnly)
|
||||
{
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
var buffer = new string[collection.Count];
|
||||
collection.CopyTo(buffer, 0);
|
||||
return new ReadOnlyCollection<string>(buffer);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeEnumerable(IEnumerable<string> scopes)
|
||||
{
|
||||
var buffer = scopes.ToArray();
|
||||
return buffer.Length == 0 ? Array.Empty<string>() : new ReadOnlyCollection<string>(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,112 +8,112 @@ using System.Text.Json.Serialization.Metadata;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic serializer used for runtime/admission contracts.
|
||||
/// </summary>
|
||||
public static class ZastavaCanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{ typeof(RuntimeEventEnvelope), new[] { "schemaVersion", "event" } },
|
||||
{ typeof(RuntimeEvent), new[] { "eventId", "when", "kind", "tenant", "node", "runtime", "workload", "process", "loadedLibs", "posture", "delta", "evidence", "annotations" } },
|
||||
{ typeof(RuntimeEngine), new[] { "engine", "version" } },
|
||||
{ typeof(RuntimeWorkload), new[] { "platform", "namespace", "pod", "container", "containerId", "imageRef", "owner" } },
|
||||
{ typeof(RuntimeWorkloadOwner), new[] { "kind", "name" } },
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic serializer used for runtime/admission contracts.
|
||||
/// </summary>
|
||||
public static class ZastavaCanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{ typeof(RuntimeEventEnvelope), new[] { "schemaVersion", "event" } },
|
||||
{ typeof(RuntimeEvent), new[] { "eventId", "when", "kind", "tenant", "node", "runtime", "workload", "process", "loadedLibs", "posture", "delta", "evidence", "annotations" } },
|
||||
{ typeof(RuntimeEngine), new[] { "engine", "version" } },
|
||||
{ typeof(RuntimeWorkload), new[] { "platform", "namespace", "pod", "container", "containerId", "imageRef", "owner" } },
|
||||
{ typeof(RuntimeWorkloadOwner), new[] { "kind", "name" } },
|
||||
{ typeof(RuntimeProcess), new[] { "pid", "entrypoint", "entryTrace", "buildId" } },
|
||||
{ typeof(RuntimeEntryTrace), new[] { "file", "line", "op", "target" } },
|
||||
{ typeof(RuntimeLoadedLibrary), new[] { "path", "inode", "sha256" } },
|
||||
{ typeof(RuntimePosture), new[] { "imageSigned", "sbomReferrer", "attestation" } },
|
||||
{ typeof(RuntimeAttestation), new[] { "uuid", "verified" } },
|
||||
{ typeof(RuntimeDelta), new[] { "baselineImageDigest", "changedFiles", "newBinaries" } },
|
||||
{ typeof(RuntimeNewBinary), new[] { "path", "sha256" } },
|
||||
{ typeof(RuntimeEvidence), new[] { "signal", "value" } },
|
||||
{ typeof(AdmissionDecisionEnvelope), new[] { "schemaVersion", "decision" } },
|
||||
{ typeof(AdmissionDecision), new[] { "admissionId", "namespace", "podSpecDigest", "images", "decision", "ttlSeconds", "annotations" } },
|
||||
{ typeof(AdmissionImageVerdict), new[] { "name", "resolved", "signed", "hasSbomReferrers", "policyVerdict", "reasons", "rekor", "metadata" } },
|
||||
{ typeof(AdmissionRekorEvidence), new[] { "uuid", "verified" } },
|
||||
{ typeof(ZastavaContractVersions.ContractVersion), new[] { "schema", "version" } }
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static byte[] SerializeToUtf8Bytes<T>(T value)
|
||||
=> JsonSerializer.SerializeToUtf8Bytes(value, CompactOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, CompactOptions)!;
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order))
|
||||
{
|
||||
var index = Array.IndexOf(order, propertyName);
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
{ typeof(RuntimeEntryTrace), new[] { "file", "line", "op", "target" } },
|
||||
{ typeof(RuntimeLoadedLibrary), new[] { "path", "inode", "sha256" } },
|
||||
{ typeof(RuntimePosture), new[] { "imageSigned", "sbomReferrer", "attestation" } },
|
||||
{ typeof(RuntimeAttestation), new[] { "uuid", "verified" } },
|
||||
{ typeof(RuntimeDelta), new[] { "baselineImageDigest", "changedFiles", "newBinaries" } },
|
||||
{ typeof(RuntimeNewBinary), new[] { "path", "sha256" } },
|
||||
{ typeof(RuntimeEvidence), new[] { "signal", "value" } },
|
||||
{ typeof(AdmissionDecisionEnvelope), new[] { "schemaVersion", "decision" } },
|
||||
{ typeof(AdmissionDecision), new[] { "admissionId", "namespace", "podSpecDigest", "images", "decision", "ttlSeconds", "annotations" } },
|
||||
{ typeof(AdmissionImageVerdict), new[] { "name", "resolved", "signed", "hasSbomReferrers", "policyVerdict", "reasons", "rekor", "metadata" } },
|
||||
{ typeof(AdmissionRekorEvidence), new[] { "uuid", "verified" } },
|
||||
{ typeof(ZastavaContractVersions.ContractVersion), new[] { "schema", "version" } }
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static byte[] SerializeToUtf8Bytes<T>(T value)
|
||||
=> JsonSerializer.SerializeToUtf8Bytes(value, CompactOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, CompactOptions)!;
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order))
|
||||
{
|
||||
var index = Array.IndexOf(order, propertyName);
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ZastavaContractVersionsTests
|
||||
{
|
||||
[Theory]
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Contracts;
|
||||
|
||||
public sealed class ZastavaContractVersionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("zastava.runtime.event@v1.0", "zastava.runtime.event", 1, 0)]
|
||||
[InlineData("zastava.admission.decision@v1.2", "zastava.admission.decision", 1, 2)]
|
||||
public void TryParse_ParsesCanonicalForms(string input, string schema, int major, int minor)
|
||||
{
|
||||
var success = ZastavaContractVersions.ContractVersion.TryParse(input, out var contract);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(schema, contract.Schema);
|
||||
Assert.Equal(new Version(major, minor), contract.Version);
|
||||
Assert.Equal($"{schema}@v{major}.{minor}", contract.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("zastava.runtime.event")]
|
||||
[InlineData("runtime@1.0")]
|
||||
[InlineData("zastava.runtime.event@vinvalid")]
|
||||
public void TryParse_InvalidInputs_ReturnsFalse(string input)
|
||||
{
|
||||
var success = ZastavaContractVersions.ContractVersion.TryParse(input, out _);
|
||||
|
||||
Assert.False(success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[InlineData("zastava.admission.decision@v1.2", "zastava.admission.decision", 1, 2)]
|
||||
public void TryParse_ParsesCanonicalForms(string input, string schema, int major, int minor)
|
||||
{
|
||||
var success = ZastavaContractVersions.ContractVersion.TryParse(input, out var contract);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(schema, contract.Schema);
|
||||
Assert.Equal(new Version(major, minor), contract.Version);
|
||||
Assert.Equal($"{schema}@v{major}.{minor}", contract.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("zastava.runtime.event")]
|
||||
[InlineData("runtime@1.0")]
|
||||
[InlineData("zastava.runtime.event@vinvalid")]
|
||||
public void TryParse_InvalidInputs_ReturnsFalse(string input)
|
||||
{
|
||||
var success = ZastavaContractVersions.ContractVersion.TryParse(input, out _);
|
||||
|
||||
Assert.False(success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRuntimeEventSupported_RespectsMajorCompatibility()
|
||||
{
|
||||
Assert.True(ZastavaContractVersions.ContractVersion.TryParse("zastava.runtime.event@v1.0", out var candidate));
|
||||
@@ -54,9 +54,9 @@ public sealed class ZastavaContractVersionsTests
|
||||
{
|
||||
var negotiated = ZastavaContractVersions.NegotiateRuntimeEvent(new[]
|
||||
{
|
||||
"zastava.runtime.event@v1.0",
|
||||
"zastava.runtime.event@v0.9",
|
||||
"zastava.admission.decision@v1"
|
||||
"zastava.runtime.event@v1.0",
|
||||
"zastava.runtime.event@v0.9",
|
||||
"zastava.admission.decision@v1"
|
||||
});
|
||||
|
||||
Assert.Equal("zastava.runtime.event@v1.0", negotiated.ToString());
|
||||
@@ -80,9 +80,9 @@ public sealed class ZastavaContractVersionsTests
|
||||
{
|
||||
var negotiated = ZastavaContractVersions.NegotiateRuntimeEvent(new[]
|
||||
{
|
||||
"zastava.runtime.event@v2.0",
|
||||
"zastava.admission.decision@v2.0"
|
||||
});
|
||||
"zastava.runtime.event@v2.0",
|
||||
"zastava.admission.decision@v2.0"
|
||||
});
|
||||
|
||||
Assert.Equal(ZastavaContractVersions.RuntimeEvent.ToString(), negotiated.ToString());
|
||||
}
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.DependencyInjection;
|
||||
|
||||
public sealed class ZastavaServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddZastavaRuntimeCore_BindsOptionsAndProvidesDiagnostics()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["zastava:runtime:tenant"] = "tenant-42",
|
||||
["zastava:runtime:environment"] = "prod",
|
||||
["zastava:runtime:deployment"] = "cluster-a",
|
||||
["zastava:runtime:metrics:meterName"] = "stellaops.zastava.runtime",
|
||||
["zastava:runtime:metrics:meterVersion"] = "2.0.0",
|
||||
["zastava:runtime:metrics:commonTags:cluster"] = "prod-cluster",
|
||||
["zastava:runtime:logging:staticScope:plane"] = "runtime",
|
||||
["zastava:runtime:authority:clientId"] = "zastava-observer",
|
||||
["zastava:runtime:authority:audience:0"] = "scanner",
|
||||
["zastava:runtime:authority:audience:1"] = "zastava",
|
||||
["zastava:runtime:authority:scopes:0"] = "aud:scanner",
|
||||
["zastava:runtime:authority:scopes:1"] = "api:scanner.runtime.write",
|
||||
["zastava:runtime:authority:allowStaticTokenFallback"] = "false"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZastavaRuntimeCore(configuration, componentName: "observer");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var runtimeOptions = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
|
||||
Assert.Equal("tenant-42", runtimeOptions.Tenant);
|
||||
Assert.Equal("prod", runtimeOptions.Environment);
|
||||
Assert.Equal("observer", runtimeOptions.Component);
|
||||
Assert.Equal("cluster-a", runtimeOptions.Deployment);
|
||||
Assert.Equal("stellaops.zastava.runtime", runtimeOptions.Metrics.MeterName);
|
||||
Assert.Equal("2.0.0", runtimeOptions.Metrics.MeterVersion);
|
||||
Assert.Equal("runtime", runtimeOptions.Logging.StaticScope["plane"]);
|
||||
Assert.Equal("zastava-observer", runtimeOptions.Authority.ClientId);
|
||||
Assert.Contains("scanner", runtimeOptions.Authority.Audience);
|
||||
Assert.Contains("zastava", runtimeOptions.Authority.Audience);
|
||||
Assert.Equal(new[] { "aud:scanner", "api:scanner.runtime.write" }, runtimeOptions.Authority.Scopes);
|
||||
Assert.False(runtimeOptions.Authority.AllowStaticTokenFallback);
|
||||
|
||||
var scopeBuilder = provider.GetRequiredService<IZastavaLogScopeBuilder>();
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
correlationId: "corr-1",
|
||||
node: "node-1",
|
||||
workload: "payments/api",
|
||||
eventId: "evt-123",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["pod"] = "api-12345"
|
||||
});
|
||||
|
||||
Assert.Equal("tenant-42", scope["tenant"]);
|
||||
Assert.Equal("observer", scope["component"]);
|
||||
Assert.Equal("prod", scope["environment"]);
|
||||
Assert.Equal("cluster-a", scope["deployment"]);
|
||||
Assert.Equal("runtime", scope["plane"]);
|
||||
Assert.Equal("corr-1", scope["correlationId"]);
|
||||
Assert.Equal("node-1", scope["node"]);
|
||||
Assert.Equal("payments/api", scope["workload"]);
|
||||
Assert.Equal("evt-123", scope["eventId"]);
|
||||
Assert.Equal("api-12345", scope["pod"]);
|
||||
|
||||
var metrics = provider.GetRequiredService<IZastavaRuntimeMetrics>();
|
||||
Assert.Equal("stellaops.zastava.runtime", metrics.Meter.Name);
|
||||
Assert.Equal("2.0.0", metrics.Meter.Version);
|
||||
|
||||
var authorityProvider = provider.GetRequiredService<IZastavaAuthorityTokenProvider>();
|
||||
Assert.NotNull(authorityProvider);
|
||||
|
||||
var defaultTags = metrics.DefaultTags.ToArray();
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "tenant" && (string?)kvp.Value == "tenant-42");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "component" && (string?)kvp.Value == "observer");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "environment" && (string?)kvp.Value == "prod");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "deployment" && (string?)kvp.Value == "cluster-a");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "cluster" && (string?)kvp.Value == "prod-cluster");
|
||||
|
||||
metrics.RuntimeEvents.Add(1, defaultTags);
|
||||
metrics.AdmissionDecisions.Add(1, defaultTags);
|
||||
metrics.BackendLatencyMs.Record(12.5, defaultTags);
|
||||
|
||||
var loggerFactoryOptions = provider.GetRequiredService<IOptionsMonitor<LoggerFactoryOptions>>().CurrentValue;
|
||||
Assert.True(loggerFactoryOptions.ActivityTrackingOptions.HasFlag(ActivityTrackingOptions.TraceId));
|
||||
Assert.True(loggerFactoryOptions.ActivityTrackingOptions.HasFlag(ActivityTrackingOptions.SpanId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZastavaRuntimeCore_ThrowsForInvalidTenant()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["zastava:runtime:tenant"] = "",
|
||||
["zastava:runtime:environment"] = "prod"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZastavaRuntimeCore(configuration, "observer");
|
||||
|
||||
Assert.Throws<OptionsValidationException>(() =>
|
||||
{
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.DependencyInjection;
|
||||
|
||||
public sealed class ZastavaServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddZastavaRuntimeCore_BindsOptionsAndProvidesDiagnostics()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["zastava:runtime:tenant"] = "tenant-42",
|
||||
["zastava:runtime:environment"] = "prod",
|
||||
["zastava:runtime:deployment"] = "cluster-a",
|
||||
["zastava:runtime:metrics:meterName"] = "stellaops.zastava.runtime",
|
||||
["zastava:runtime:metrics:meterVersion"] = "2.0.0",
|
||||
["zastava:runtime:metrics:commonTags:cluster"] = "prod-cluster",
|
||||
["zastava:runtime:logging:staticScope:plane"] = "runtime",
|
||||
["zastava:runtime:authority:clientId"] = "zastava-observer",
|
||||
["zastava:runtime:authority:audience:0"] = "scanner",
|
||||
["zastava:runtime:authority:audience:1"] = "zastava",
|
||||
["zastava:runtime:authority:scopes:0"] = "aud:scanner",
|
||||
["zastava:runtime:authority:scopes:1"] = "api:scanner.runtime.write",
|
||||
["zastava:runtime:authority:allowStaticTokenFallback"] = "false"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZastavaRuntimeCore(configuration, componentName: "observer");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var runtimeOptions = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
|
||||
Assert.Equal("tenant-42", runtimeOptions.Tenant);
|
||||
Assert.Equal("prod", runtimeOptions.Environment);
|
||||
Assert.Equal("observer", runtimeOptions.Component);
|
||||
Assert.Equal("cluster-a", runtimeOptions.Deployment);
|
||||
Assert.Equal("stellaops.zastava.runtime", runtimeOptions.Metrics.MeterName);
|
||||
Assert.Equal("2.0.0", runtimeOptions.Metrics.MeterVersion);
|
||||
Assert.Equal("runtime", runtimeOptions.Logging.StaticScope["plane"]);
|
||||
Assert.Equal("zastava-observer", runtimeOptions.Authority.ClientId);
|
||||
Assert.Contains("scanner", runtimeOptions.Authority.Audience);
|
||||
Assert.Contains("zastava", runtimeOptions.Authority.Audience);
|
||||
Assert.Equal(new[] { "aud:scanner", "api:scanner.runtime.write" }, runtimeOptions.Authority.Scopes);
|
||||
Assert.False(runtimeOptions.Authority.AllowStaticTokenFallback);
|
||||
|
||||
var scopeBuilder = provider.GetRequiredService<IZastavaLogScopeBuilder>();
|
||||
var scope = scopeBuilder.BuildScope(
|
||||
correlationId: "corr-1",
|
||||
node: "node-1",
|
||||
workload: "payments/api",
|
||||
eventId: "evt-123",
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["pod"] = "api-12345"
|
||||
});
|
||||
|
||||
Assert.Equal("tenant-42", scope["tenant"]);
|
||||
Assert.Equal("observer", scope["component"]);
|
||||
Assert.Equal("prod", scope["environment"]);
|
||||
Assert.Equal("cluster-a", scope["deployment"]);
|
||||
Assert.Equal("runtime", scope["plane"]);
|
||||
Assert.Equal("corr-1", scope["correlationId"]);
|
||||
Assert.Equal("node-1", scope["node"]);
|
||||
Assert.Equal("payments/api", scope["workload"]);
|
||||
Assert.Equal("evt-123", scope["eventId"]);
|
||||
Assert.Equal("api-12345", scope["pod"]);
|
||||
|
||||
var metrics = provider.GetRequiredService<IZastavaRuntimeMetrics>();
|
||||
Assert.Equal("stellaops.zastava.runtime", metrics.Meter.Name);
|
||||
Assert.Equal("2.0.0", metrics.Meter.Version);
|
||||
|
||||
var authorityProvider = provider.GetRequiredService<IZastavaAuthorityTokenProvider>();
|
||||
Assert.NotNull(authorityProvider);
|
||||
|
||||
var defaultTags = metrics.DefaultTags.ToArray();
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "tenant" && (string?)kvp.Value == "tenant-42");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "component" && (string?)kvp.Value == "observer");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "environment" && (string?)kvp.Value == "prod");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "deployment" && (string?)kvp.Value == "cluster-a");
|
||||
Assert.Contains(defaultTags, kvp => kvp.Key == "cluster" && (string?)kvp.Value == "prod-cluster");
|
||||
|
||||
metrics.RuntimeEvents.Add(1, defaultTags);
|
||||
metrics.AdmissionDecisions.Add(1, defaultTags);
|
||||
metrics.BackendLatencyMs.Record(12.5, defaultTags);
|
||||
|
||||
var loggerFactoryOptions = provider.GetRequiredService<IOptionsMonitor<LoggerFactoryOptions>>().CurrentValue;
|
||||
Assert.True(loggerFactoryOptions.ActivityTrackingOptions.HasFlag(ActivityTrackingOptions.TraceId));
|
||||
Assert.True(loggerFactoryOptions.ActivityTrackingOptions.HasFlag(ActivityTrackingOptions.SpanId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZastavaRuntimeCore_ThrowsForInvalidTenant()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["zastava:runtime:tenant"] = "",
|
||||
["zastava:runtime:environment"] = "prod"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZastavaRuntimeCore(configuration, "observer");
|
||||
|
||||
Assert.Throws<OptionsValidationException>(() =>
|
||||
{
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Security;
|
||||
|
||||
public sealed class ZastavaAuthorityTokenProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_UsesCacheUntilRefreshWindow()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-23T12:00:00Z"));
|
||||
var runtimeOptions = CreateRuntimeOptions(refreshSkewSeconds: 120);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token-1",
|
||||
"DPoP",
|
||||
timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10),
|
||||
new[] { "aud:scanner", "api:scanner.runtime.write" }));
|
||||
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token-2",
|
||||
"DPoP",
|
||||
timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10),
|
||||
new[] { "aud:scanner", "api:scanner.runtime.write" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, timeProvider);
|
||||
|
||||
var tokenA = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-1", tokenA.AccessToken);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
// Move time forward but still before refresh window (refresh skew = 2 minutes)
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var tokenB = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-1", tokenB.AccessToken);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
// Cross refresh window to trigger renewal
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var tokenC = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-2", tokenC.AccessToken);
|
||||
Assert.Equal(2, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ThrowsWhenMissingAudienceScope()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions();
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token",
|
||||
"DPoP",
|
||||
DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5),
|
||||
new[] { "api:scanner.runtime.write" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAsync("scanner").AsTask());
|
||||
Assert.Contains("audience scope", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_StaticFallbackUsedWhenEnabled()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions(allowFallback: true, staticToken: "static-token", requireDpop: false);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.FailWith(new InvalidOperationException("offline"));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
var token = await provider.GetAsync("scanner");
|
||||
Assert.Equal("static-token", token.AccessToken);
|
||||
Assert.Null(token.ExpiresAtUtc);
|
||||
Assert.Equal(0, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ThrowsWhenDpopRequiredButTokenTypeIsBearer()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions(requireDpop: true);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token",
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5),
|
||||
new[] { "aud:scanner" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAsync("scanner").AsTask());
|
||||
}
|
||||
|
||||
private static ZastavaRuntimeOptions CreateRuntimeOptions(
|
||||
double refreshSkewSeconds = 60,
|
||||
bool allowFallback = false,
|
||||
string? staticToken = null,
|
||||
bool requireDpop = true)
|
||||
=> new()
|
||||
{
|
||||
Tenant = "tenant-x",
|
||||
Environment = "test",
|
||||
Component = "observer",
|
||||
Authority = new ZastavaAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.internal"),
|
||||
ClientId = "zastava-runtime",
|
||||
Audience = new[] { "scanner" },
|
||||
Scopes = new[] { "api:scanner.runtime.write" },
|
||||
RefreshSkewSeconds = refreshSkewSeconds,
|
||||
RequireDpop = requireDpop,
|
||||
RequireMutualTls = true,
|
||||
AllowStaticTokenFallback = allowFallback,
|
||||
StaticTokenValue = staticToken
|
||||
}
|
||||
};
|
||||
|
||||
private static ZastavaAuthorityTokenProvider CreateProvider(
|
||||
ZastavaRuntimeOptions runtimeOptions,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaRuntimeOptions>(runtimeOptions);
|
||||
var scopeBuilder = new ZastavaLogScopeBuilder(Options.Create(runtimeOptions));
|
||||
return new ZastavaAuthorityTokenProvider(
|
||||
tokenClient,
|
||||
optionsMonitor,
|
||||
scopeBuilder,
|
||||
timeProvider,
|
||||
NullLogger<ZastavaAuthorityTokenProvider>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private readonly Queue<Func<CancellationToken, Task<StellaOpsTokenResult>>> responses = new();
|
||||
private Exception? failure;
|
||||
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public void EnqueueToken(StellaOpsTokenResult result)
|
||||
=> responses.Enqueue(_ => Task.FromResult(result));
|
||||
|
||||
public void FailWith(Exception exception)
|
||||
=> failure = exception;
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
throw failure;
|
||||
}
|
||||
|
||||
if (responses.TryDequeue(out var factory))
|
||||
{
|
||||
return factory(cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No token responses queued.");
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset current;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset initial)
|
||||
{
|
||||
current = initial;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => current;
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
current = current.Add(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Security;
|
||||
|
||||
public sealed class ZastavaAuthorityTokenProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_UsesCacheUntilRefreshWindow()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-23T12:00:00Z"));
|
||||
var runtimeOptions = CreateRuntimeOptions(refreshSkewSeconds: 120);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token-1",
|
||||
"DPoP",
|
||||
timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10),
|
||||
new[] { "aud:scanner", "api:scanner.runtime.write" }));
|
||||
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token-2",
|
||||
"DPoP",
|
||||
timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10),
|
||||
new[] { "aud:scanner", "api:scanner.runtime.write" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, timeProvider);
|
||||
|
||||
var tokenA = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-1", tokenA.AccessToken);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
// Move time forward but still before refresh window (refresh skew = 2 minutes)
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var tokenB = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-1", tokenB.AccessToken);
|
||||
Assert.Equal(1, tokenClient.RequestCount);
|
||||
|
||||
// Cross refresh window to trigger renewal
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var tokenC = await provider.GetAsync("scanner");
|
||||
Assert.Equal("token-2", tokenC.AccessToken);
|
||||
Assert.Equal(2, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ThrowsWhenMissingAudienceScope()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions();
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token",
|
||||
"DPoP",
|
||||
DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5),
|
||||
new[] { "api:scanner.runtime.write" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAsync("scanner").AsTask());
|
||||
Assert.Contains("audience scope", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_StaticFallbackUsedWhenEnabled()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions(allowFallback: true, staticToken: "static-token", requireDpop: false);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.FailWith(new InvalidOperationException("offline"));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
var token = await provider.GetAsync("scanner");
|
||||
Assert.Equal("static-token", token.AccessToken);
|
||||
Assert.Null(token.ExpiresAtUtc);
|
||||
Assert.Equal(0, tokenClient.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ThrowsWhenDpopRequiredButTokenTypeIsBearer()
|
||||
{
|
||||
var runtimeOptions = CreateRuntimeOptions(requireDpop: true);
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.EnqueueToken(new StellaOpsTokenResult(
|
||||
"token",
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5),
|
||||
new[] { "aud:scanner" }));
|
||||
|
||||
var provider = CreateProvider(runtimeOptions, tokenClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAsync("scanner").AsTask());
|
||||
}
|
||||
|
||||
private static ZastavaRuntimeOptions CreateRuntimeOptions(
|
||||
double refreshSkewSeconds = 60,
|
||||
bool allowFallback = false,
|
||||
string? staticToken = null,
|
||||
bool requireDpop = true)
|
||||
=> new()
|
||||
{
|
||||
Tenant = "tenant-x",
|
||||
Environment = "test",
|
||||
Component = "observer",
|
||||
Authority = new ZastavaAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.internal"),
|
||||
ClientId = "zastava-runtime",
|
||||
Audience = new[] { "scanner" },
|
||||
Scopes = new[] { "api:scanner.runtime.write" },
|
||||
RefreshSkewSeconds = refreshSkewSeconds,
|
||||
RequireDpop = requireDpop,
|
||||
RequireMutualTls = true,
|
||||
AllowStaticTokenFallback = allowFallback,
|
||||
StaticTokenValue = staticToken
|
||||
}
|
||||
};
|
||||
|
||||
private static ZastavaAuthorityTokenProvider CreateProvider(
|
||||
ZastavaRuntimeOptions runtimeOptions,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaRuntimeOptions>(runtimeOptions);
|
||||
var scopeBuilder = new ZastavaLogScopeBuilder(Options.Create(runtimeOptions));
|
||||
return new ZastavaAuthorityTokenProvider(
|
||||
tokenClient,
|
||||
optionsMonitor,
|
||||
scopeBuilder,
|
||||
timeProvider,
|
||||
NullLogger<ZastavaAuthorityTokenProvider>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StubTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private readonly Queue<Func<CancellationToken, Task<StellaOpsTokenResult>>> responses = new();
|
||||
private Exception? failure;
|
||||
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
|
||||
|
||||
public void EnqueueToken(StellaOpsTokenResult result)
|
||||
=> responses.Enqueue(_ => Task.FromResult(result));
|
||||
|
||||
public void FailWith(Exception exception)
|
||||
=> failure = exception;
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
LastAdditionalParameters = additionalParameters;
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
throw failure;
|
||||
}
|
||||
|
||||
if (responses.TryDequeue(out var factory))
|
||||
{
|
||||
return factory(cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No token responses queued.");
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset current;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset initial)
|
||||
{
|
||||
current = initial;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => current;
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
current = current.Add(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Hashing;
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Serialization;
|
||||
|
||||
public sealed class ZastavaCanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_RuntimeEventEnvelope_ProducesDeterministicOrdering()
|
||||
{
|
||||
var runtimeEvent = new RuntimeEvent
|
||||
{
|
||||
EventId = "evt-123",
|
||||
When = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
||||
Kind = RuntimeEventKind.ContainerStart,
|
||||
Tenant = "tenant-01",
|
||||
Node = "node-a",
|
||||
Runtime = new RuntimeEngine
|
||||
{
|
||||
Engine = "containerd",
|
||||
Version = "1.7.19"
|
||||
},
|
||||
Workload = new RuntimeWorkload
|
||||
{
|
||||
Platform = "kubernetes",
|
||||
Namespace = "payments",
|
||||
Pod = "api-7c9fbbd8b7-ktd84",
|
||||
Container = "api",
|
||||
ContainerId = "containerd://abc",
|
||||
ImageRef = "ghcr.io/acme/api@sha256:abcd",
|
||||
Owner = new RuntimeWorkloadOwner
|
||||
{
|
||||
Kind = "Deployment",
|
||||
Name = "api"
|
||||
}
|
||||
},
|
||||
Process = new RuntimeProcess
|
||||
{
|
||||
Pid = 12345,
|
||||
Entrypoint = new[] { "/entrypoint.sh", "--serve" },
|
||||
EntryTrace = new[]
|
||||
{
|
||||
new RuntimeEntryTrace
|
||||
{
|
||||
File = "/entrypoint.sh",
|
||||
Line = 3,
|
||||
Op = "exec",
|
||||
Target = "/usr/bin/python3"
|
||||
}
|
||||
}
|
||||
},
|
||||
LoadedLibraries = new[]
|
||||
{
|
||||
new RuntimeLoadedLibrary
|
||||
{
|
||||
Path = "/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
Inode = 123456,
|
||||
Sha256 = "abc123"
|
||||
}
|
||||
},
|
||||
Posture = new RuntimePosture
|
||||
{
|
||||
ImageSigned = true,
|
||||
SbomReferrer = "present",
|
||||
Attestation = new RuntimeAttestation
|
||||
{
|
||||
Uuid = "rekor-uuid",
|
||||
Verified = true
|
||||
}
|
||||
},
|
||||
Delta = new RuntimeDelta
|
||||
{
|
||||
BaselineImageDigest = "sha256:abcd",
|
||||
ChangedFiles = new[] { "/opt/app/server.py" },
|
||||
NewBinaries = new[]
|
||||
{
|
||||
new RuntimeNewBinary
|
||||
{
|
||||
Path = "/usr/local/bin/helper",
|
||||
Sha256 = "def456"
|
||||
}
|
||||
}
|
||||
},
|
||||
Evidence = new[]
|
||||
{
|
||||
new RuntimeEvidence
|
||||
{
|
||||
Signal = "procfs.maps",
|
||||
Value = "/lib/.../libssl.so.3@0x7f..."
|
||||
}
|
||||
},
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "unit-test"
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
||||
var json = ZastavaCanonicalJsonSerializer.Serialize(envelope);
|
||||
|
||||
var expectedOrder = new[]
|
||||
{
|
||||
"\"schemaVersion\"",
|
||||
"\"event\"",
|
||||
"\"eventId\"",
|
||||
"\"when\"",
|
||||
"\"kind\"",
|
||||
"\"tenant\"",
|
||||
"\"node\"",
|
||||
"\"runtime\"",
|
||||
"\"engine\"",
|
||||
"\"version\"",
|
||||
"\"workload\"",
|
||||
"\"platform\"",
|
||||
"\"namespace\"",
|
||||
"\"pod\"",
|
||||
"\"container\"",
|
||||
"\"containerId\"",
|
||||
"\"imageRef\"",
|
||||
"\"owner\"",
|
||||
"\"kind\"",
|
||||
"\"name\"",
|
||||
"\"process\"",
|
||||
"\"pid\"",
|
||||
"\"entrypoint\"",
|
||||
"\"entryTrace\"",
|
||||
"\"loadedLibs\"",
|
||||
"\"posture\"",
|
||||
"\"imageSigned\"",
|
||||
"\"sbomReferrer\"",
|
||||
"\"attestation\"",
|
||||
"\"uuid\"",
|
||||
"\"verified\"",
|
||||
"\"delta\"",
|
||||
"\"baselineImageDigest\"",
|
||||
"\"changedFiles\"",
|
||||
"\"newBinaries\"",
|
||||
"\"path\"",
|
||||
"\"sha256\"",
|
||||
"\"evidence\"",
|
||||
"\"signal\"",
|
||||
"\"value\"",
|
||||
"\"annotations\"",
|
||||
"\"source\""
|
||||
};
|
||||
|
||||
var cursor = -1;
|
||||
foreach (var token in expectedOrder)
|
||||
{
|
||||
var position = json.IndexOf(token, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(position > cursor, $"Property token {token} not found in the expected order.");
|
||||
cursor = position;
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(" ", json, StringComparison.Ordinal);
|
||||
Assert.StartsWith("{\"schemaVersion\"", json, StringComparison.Ordinal);
|
||||
Assert.EndsWith("}}", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_ProducesStableBase64UrlDigest()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"value\":42}");
|
||||
var expectedDigestBytes = SHA256.HashData(payloadBytes);
|
||||
var expected = $"sha256-{Convert.ToBase64String(expectedDigestBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}";
|
||||
|
||||
var hash = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(payloadBytes));
|
||||
|
||||
Assert.Equal(expected, hash);
|
||||
|
||||
var sha512 = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(payloadBytes), "sha512");
|
||||
Assert.StartsWith("sha512-", sha512, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_NormalizesAlgorithmAliases()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("sample");
|
||||
var digestDefault = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(bytes));
|
||||
var digestAlias = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(bytes), "sha-256");
|
||||
|
||||
Assert.Equal(digestDefault, digestAlias);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_UnknownAlgorithm_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<NotSupportedException>(() => ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(Array.Empty<byte>()), "unsupported"));
|
||||
Assert.Contains("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Hashing;
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Serialization;
|
||||
|
||||
public sealed class ZastavaCanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_RuntimeEventEnvelope_ProducesDeterministicOrdering()
|
||||
{
|
||||
var runtimeEvent = new RuntimeEvent
|
||||
{
|
||||
EventId = "evt-123",
|
||||
When = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
||||
Kind = RuntimeEventKind.ContainerStart,
|
||||
Tenant = "tenant-01",
|
||||
Node = "node-a",
|
||||
Runtime = new RuntimeEngine
|
||||
{
|
||||
Engine = "containerd",
|
||||
Version = "1.7.19"
|
||||
},
|
||||
Workload = new RuntimeWorkload
|
||||
{
|
||||
Platform = "kubernetes",
|
||||
Namespace = "payments",
|
||||
Pod = "api-7c9fbbd8b7-ktd84",
|
||||
Container = "api",
|
||||
ContainerId = "containerd://abc",
|
||||
ImageRef = "ghcr.io/acme/api@sha256:abcd",
|
||||
Owner = new RuntimeWorkloadOwner
|
||||
{
|
||||
Kind = "Deployment",
|
||||
Name = "api"
|
||||
}
|
||||
},
|
||||
Process = new RuntimeProcess
|
||||
{
|
||||
Pid = 12345,
|
||||
Entrypoint = new[] { "/entrypoint.sh", "--serve" },
|
||||
EntryTrace = new[]
|
||||
{
|
||||
new RuntimeEntryTrace
|
||||
{
|
||||
File = "/entrypoint.sh",
|
||||
Line = 3,
|
||||
Op = "exec",
|
||||
Target = "/usr/bin/python3"
|
||||
}
|
||||
}
|
||||
},
|
||||
LoadedLibraries = new[]
|
||||
{
|
||||
new RuntimeLoadedLibrary
|
||||
{
|
||||
Path = "/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
Inode = 123456,
|
||||
Sha256 = "abc123"
|
||||
}
|
||||
},
|
||||
Posture = new RuntimePosture
|
||||
{
|
||||
ImageSigned = true,
|
||||
SbomReferrer = "present",
|
||||
Attestation = new RuntimeAttestation
|
||||
{
|
||||
Uuid = "rekor-uuid",
|
||||
Verified = true
|
||||
}
|
||||
},
|
||||
Delta = new RuntimeDelta
|
||||
{
|
||||
BaselineImageDigest = "sha256:abcd",
|
||||
ChangedFiles = new[] { "/opt/app/server.py" },
|
||||
NewBinaries = new[]
|
||||
{
|
||||
new RuntimeNewBinary
|
||||
{
|
||||
Path = "/usr/local/bin/helper",
|
||||
Sha256 = "def456"
|
||||
}
|
||||
}
|
||||
},
|
||||
Evidence = new[]
|
||||
{
|
||||
new RuntimeEvidence
|
||||
{
|
||||
Signal = "procfs.maps",
|
||||
Value = "/lib/.../libssl.so.3@0x7f..."
|
||||
}
|
||||
},
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "unit-test"
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
||||
var json = ZastavaCanonicalJsonSerializer.Serialize(envelope);
|
||||
|
||||
var expectedOrder = new[]
|
||||
{
|
||||
"\"schemaVersion\"",
|
||||
"\"event\"",
|
||||
"\"eventId\"",
|
||||
"\"when\"",
|
||||
"\"kind\"",
|
||||
"\"tenant\"",
|
||||
"\"node\"",
|
||||
"\"runtime\"",
|
||||
"\"engine\"",
|
||||
"\"version\"",
|
||||
"\"workload\"",
|
||||
"\"platform\"",
|
||||
"\"namespace\"",
|
||||
"\"pod\"",
|
||||
"\"container\"",
|
||||
"\"containerId\"",
|
||||
"\"imageRef\"",
|
||||
"\"owner\"",
|
||||
"\"kind\"",
|
||||
"\"name\"",
|
||||
"\"process\"",
|
||||
"\"pid\"",
|
||||
"\"entrypoint\"",
|
||||
"\"entryTrace\"",
|
||||
"\"loadedLibs\"",
|
||||
"\"posture\"",
|
||||
"\"imageSigned\"",
|
||||
"\"sbomReferrer\"",
|
||||
"\"attestation\"",
|
||||
"\"uuid\"",
|
||||
"\"verified\"",
|
||||
"\"delta\"",
|
||||
"\"baselineImageDigest\"",
|
||||
"\"changedFiles\"",
|
||||
"\"newBinaries\"",
|
||||
"\"path\"",
|
||||
"\"sha256\"",
|
||||
"\"evidence\"",
|
||||
"\"signal\"",
|
||||
"\"value\"",
|
||||
"\"annotations\"",
|
||||
"\"source\""
|
||||
};
|
||||
|
||||
var cursor = -1;
|
||||
foreach (var token in expectedOrder)
|
||||
{
|
||||
var position = json.IndexOf(token, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(position > cursor, $"Property token {token} not found in the expected order.");
|
||||
cursor = position;
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(" ", json, StringComparison.Ordinal);
|
||||
Assert.StartsWith("{\"schemaVersion\"", json, StringComparison.Ordinal);
|
||||
Assert.EndsWith("}}", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_ProducesStableBase64UrlDigest()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"value\":42}");
|
||||
var expectedDigestBytes = SHA256.HashData(payloadBytes);
|
||||
var expected = $"sha256-{Convert.ToBase64String(expectedDigestBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}";
|
||||
|
||||
var hash = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(payloadBytes));
|
||||
|
||||
Assert.Equal(expected, hash);
|
||||
|
||||
var sha512 = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(payloadBytes), "sha512");
|
||||
Assert.StartsWith("sha512-", sha512, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_NormalizesAlgorithmAliases()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("sample");
|
||||
var digestDefault = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(bytes));
|
||||
var digestAlias = ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(bytes), "sha-256");
|
||||
|
||||
Assert.Equal(digestDefault, digestAlias);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMultihash_UnknownAlgorithm_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<NotSupportedException>(() => ZastavaHashing.ComputeMultihash(new ReadOnlySpan<byte>(Array.Empty<byte>()), "unsupported"));
|
||||
Assert.Contains("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using StellaOps.Zastava.Observer.Worker;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Tests.Worker;
|
||||
|
||||
public sealed class RuntimeEventFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using StellaOps.Zastava.Observer.Worker;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Tests.Worker;
|
||||
|
||||
public sealed class RuntimeEventFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_AttachesBuildIdFromProcessCapture()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var snapshot = new CriContainerInfo(
|
||||
Id: "container-a",
|
||||
PodSandboxId: "sandbox-a",
|
||||
Name: "api",
|
||||
Attempt: 1,
|
||||
Image: "ghcr.io/example/api:1.0",
|
||||
ImageRef: "ghcr.io/example/api@sha256:deadbeef",
|
||||
Labels: new Dictionary<string, string>
|
||||
{
|
||||
[CriLabelKeys.PodName] = "api-abc",
|
||||
[CriLabelKeys.PodNamespace] = "payments",
|
||||
[CriLabelKeys.ContainerName] = "api"
|
||||
},
|
||||
Annotations: new Dictionary<string, string>(),
|
||||
CreatedAt: timestamp,
|
||||
StartedAt: timestamp,
|
||||
FinishedAt: null,
|
||||
ExitCode: null,
|
||||
Reason: null,
|
||||
Message: null,
|
||||
Pid: 4321);
|
||||
|
||||
var lifecycleEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
var endpoint = new ContainerRuntimeEndpointOptions
|
||||
{
|
||||
Engine = ContainerRuntimeEngine.Containerd,
|
||||
Endpoint = "unix:///run/containerd/containerd.sock",
|
||||
Name = "containerd"
|
||||
};
|
||||
var identity = new CriRuntimeIdentity("containerd", "1.7.19", "v1");
|
||||
var process = new RuntimeProcess
|
||||
{
|
||||
Pid = 4321,
|
||||
Entrypoint = new[] { "/entrypoint.sh" },
|
||||
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
||||
BuildId = "5f0c7c3cb4d9f8a4"
|
||||
};
|
||||
var capture = new RuntimeProcessCapture(
|
||||
process,
|
||||
Array.Empty<RuntimeLoadedLibrary>(),
|
||||
new List<RuntimeEvidence>());
|
||||
|
||||
var envelope = RuntimeEventFactory.Create(
|
||||
lifecycleEvent,
|
||||
endpoint,
|
||||
identity,
|
||||
tenant: "tenant-alpha",
|
||||
nodeName: "node-1",
|
||||
capture: capture,
|
||||
posture: null,
|
||||
additionalEvidence: null);
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var snapshot = new CriContainerInfo(
|
||||
Id: "container-a",
|
||||
PodSandboxId: "sandbox-a",
|
||||
Name: "api",
|
||||
Attempt: 1,
|
||||
Image: "ghcr.io/example/api:1.0",
|
||||
ImageRef: "ghcr.io/example/api@sha256:deadbeef",
|
||||
Labels: new Dictionary<string, string>
|
||||
{
|
||||
[CriLabelKeys.PodName] = "api-abc",
|
||||
[CriLabelKeys.PodNamespace] = "payments",
|
||||
[CriLabelKeys.ContainerName] = "api"
|
||||
},
|
||||
Annotations: new Dictionary<string, string>(),
|
||||
CreatedAt: timestamp,
|
||||
StartedAt: timestamp,
|
||||
FinishedAt: null,
|
||||
ExitCode: null,
|
||||
Reason: null,
|
||||
Message: null,
|
||||
Pid: 4321);
|
||||
|
||||
var lifecycleEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||
var endpoint = new ContainerRuntimeEndpointOptions
|
||||
{
|
||||
Engine = ContainerRuntimeEngine.Containerd,
|
||||
Endpoint = "unix:///run/containerd/containerd.sock",
|
||||
Name = "containerd"
|
||||
};
|
||||
var identity = new CriRuntimeIdentity("containerd", "1.7.19", "v1");
|
||||
var process = new RuntimeProcess
|
||||
{
|
||||
Pid = 4321,
|
||||
Entrypoint = new[] { "/entrypoint.sh" },
|
||||
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
||||
BuildId = "5f0c7c3cb4d9f8a4"
|
||||
};
|
||||
var capture = new RuntimeProcessCapture(
|
||||
process,
|
||||
Array.Empty<RuntimeLoadedLibrary>(),
|
||||
new List<RuntimeEvidence>());
|
||||
|
||||
var envelope = RuntimeEventFactory.Create(
|
||||
lifecycleEvent,
|
||||
endpoint,
|
||||
identity,
|
||||
tenant: "tenant-alpha",
|
||||
nodeName: "node-1",
|
||||
capture: capture,
|
||||
posture: null,
|
||||
additionalEvidence: null);
|
||||
|
||||
Assert.NotNull(envelope.Event.Process);
|
||||
Assert.Equal("5f0c7c3cb4d9f8a4", envelope.Event.Process!.BuildId);
|
||||
}
|
||||
|
||||
@@ -1,198 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Backend;
|
||||
|
||||
public sealed class RuntimePolicyClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SendsDpOpHeaderAndParsesResponse()
|
||||
{
|
||||
var requestCapture = new List<HttpRequestMessage>();
|
||||
var handler = new StubHttpMessageHandler(message =>
|
||||
{
|
||||
requestCapture.Add(message);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
ttlSeconds = 120,
|
||||
results = new
|
||||
{
|
||||
image = new
|
||||
{
|
||||
signed = true,
|
||||
hasSbom = true,
|
||||
policyVerdict = "pass",
|
||||
reasons = Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
}), Encoding.UTF8, "application/json")
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.internal")
|
||||
};
|
||||
|
||||
var runtimeOptions = Options.Create(new ZastavaRuntimeOptions
|
||||
{
|
||||
Tenant = "tenant-1",
|
||||
Environment = "test",
|
||||
Component = "webhook",
|
||||
Authority = new ZastavaAuthorityOptions
|
||||
{
|
||||
Audience = new[] { "scanner" },
|
||||
Scopes = new[] { "aud:scanner" }
|
||||
},
|
||||
Logging = new ZastavaRuntimeLoggingOptions(),
|
||||
Metrics = new ZastavaRuntimeMetricsOptions()
|
||||
});
|
||||
|
||||
var webhookOptions = Options.Create(new ZastavaWebhookOptions
|
||||
{
|
||||
Backend = new ZastavaWebhookBackendOptions
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.internal"),
|
||||
PolicyPath = "/api/v1/scanner/policy/runtime"
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new StubRuntimeMetrics();
|
||||
var client = new RuntimePolicyClient(
|
||||
httpClient,
|
||||
new StubAuthorityTokenProvider(),
|
||||
new StaticOptionsMonitor<ZastavaRuntimeOptions>(runtimeOptions.Value),
|
||||
new StaticOptionsMonitor<ZastavaWebhookOptions>(webhookOptions.Value),
|
||||
metrics,
|
||||
NullLogger<RuntimePolicyClient>.Instance);
|
||||
|
||||
var response = await client.EvaluateAsync(new RuntimePolicyRequest
|
||||
{
|
||||
Namespace = "payments",
|
||||
Labels = new Dictionary<string, string> { ["app"] = "api" },
|
||||
Images = new[] { "image" }
|
||||
});
|
||||
|
||||
Assert.Equal(120, response.TtlSeconds);
|
||||
Assert.True(response.Results.ContainsKey("image"));
|
||||
var request = Assert.Single(requestCapture);
|
||||
Assert.Equal("DPoP", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("runtime-token", request.Headers.Authorization?.Parameter);
|
||||
Assert.Equal("/api/v1/scanner/policy/runtime", request.RequestUri?.PathAndQuery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonSuccess_ThrowsRuntimePolicyException()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = new StringContent("upstream error")
|
||||
});
|
||||
var client = new RuntimePolicyClient(
|
||||
new HttpClient(handler) { BaseAddress = new Uri("https://scanner.internal") },
|
||||
new StubAuthorityTokenProvider(),
|
||||
new StaticOptionsMonitor<ZastavaRuntimeOptions>(new ZastavaRuntimeOptions
|
||||
{
|
||||
Tenant = "tenant",
|
||||
Environment = "test",
|
||||
Component = "webhook",
|
||||
Authority = new ZastavaAuthorityOptions { Audience = new[] { "scanner" } },
|
||||
Logging = new ZastavaRuntimeLoggingOptions(),
|
||||
Metrics = new ZastavaRuntimeMetricsOptions()
|
||||
}),
|
||||
new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions()),
|
||||
new StubRuntimeMetrics(),
|
||||
NullLogger<RuntimePolicyClient>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<RuntimePolicyException>(() => client.EvaluateAsync(new RuntimePolicyRequest
|
||||
{
|
||||
Namespace = "payments",
|
||||
Labels = null,
|
||||
Images = new[] { "image" }
|
||||
}));
|
||||
}
|
||||
|
||||
private sealed class StubAuthorityTokenProvider : IZastavaAuthorityTokenProvider
|
||||
{
|
||||
public ValueTask InvalidateAsync(string audience, IEnumerable<string>? additionalScopes = null, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<ZastavaOperationalToken> GetAsync(string audience, IEnumerable<string>? additionalScopes = null, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new ZastavaOperationalToken("runtime-token", "DPoP", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
private sealed class StubRuntimeMetrics : IZastavaRuntimeMetrics
|
||||
{
|
||||
public StubRuntimeMetrics()
|
||||
{
|
||||
Meter = new Meter("Test.Zastava.Webhook");
|
||||
RuntimeEvents = Meter.CreateCounter<long>("test.events");
|
||||
AdmissionDecisions = Meter.CreateCounter<long>("test.decisions");
|
||||
BackendLatencyMs = Meter.CreateHistogram<double>("test.backend.latency");
|
||||
DefaultTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
}
|
||||
|
||||
public Meter Meter { get; }
|
||||
|
||||
public Counter<long> RuntimeEvents { get; }
|
||||
|
||||
public Counter<long> AdmissionDecisions { get; }
|
||||
|
||||
public Histogram<double> BackendLatencyMs { get; }
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> DefaultTags { get; }
|
||||
|
||||
public void Dispose() => Meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Backend;
|
||||
|
||||
public sealed class RuntimePolicyClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SendsDpOpHeaderAndParsesResponse()
|
||||
{
|
||||
var requestCapture = new List<HttpRequestMessage>();
|
||||
var handler = new StubHttpMessageHandler(message =>
|
||||
{
|
||||
requestCapture.Add(message);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
ttlSeconds = 120,
|
||||
results = new
|
||||
{
|
||||
image = new
|
||||
{
|
||||
signed = true,
|
||||
hasSbom = true,
|
||||
policyVerdict = "pass",
|
||||
reasons = Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
}), Encoding.UTF8, "application/json")
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.internal")
|
||||
};
|
||||
|
||||
var runtimeOptions = Options.Create(new ZastavaRuntimeOptions
|
||||
{
|
||||
Tenant = "tenant-1",
|
||||
Environment = "test",
|
||||
Component = "webhook",
|
||||
Authority = new ZastavaAuthorityOptions
|
||||
{
|
||||
Audience = new[] { "scanner" },
|
||||
Scopes = new[] { "aud:scanner" }
|
||||
},
|
||||
Logging = new ZastavaRuntimeLoggingOptions(),
|
||||
Metrics = new ZastavaRuntimeMetricsOptions()
|
||||
});
|
||||
|
||||
var webhookOptions = Options.Create(new ZastavaWebhookOptions
|
||||
{
|
||||
Backend = new ZastavaWebhookBackendOptions
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.internal"),
|
||||
PolicyPath = "/api/v1/scanner/policy/runtime"
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new StubRuntimeMetrics();
|
||||
var client = new RuntimePolicyClient(
|
||||
httpClient,
|
||||
new StubAuthorityTokenProvider(),
|
||||
new StaticOptionsMonitor<ZastavaRuntimeOptions>(runtimeOptions.Value),
|
||||
new StaticOptionsMonitor<ZastavaWebhookOptions>(webhookOptions.Value),
|
||||
metrics,
|
||||
NullLogger<RuntimePolicyClient>.Instance);
|
||||
|
||||
var response = await client.EvaluateAsync(new RuntimePolicyRequest
|
||||
{
|
||||
Namespace = "payments",
|
||||
Labels = new Dictionary<string, string> { ["app"] = "api" },
|
||||
Images = new[] { "image" }
|
||||
});
|
||||
|
||||
Assert.Equal(120, response.TtlSeconds);
|
||||
Assert.True(response.Results.ContainsKey("image"));
|
||||
var request = Assert.Single(requestCapture);
|
||||
Assert.Equal("DPoP", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("runtime-token", request.Headers.Authorization?.Parameter);
|
||||
Assert.Equal("/api/v1/scanner/policy/runtime", request.RequestUri?.PathAndQuery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonSuccess_ThrowsRuntimePolicyException()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = new StringContent("upstream error")
|
||||
});
|
||||
var client = new RuntimePolicyClient(
|
||||
new HttpClient(handler) { BaseAddress = new Uri("https://scanner.internal") },
|
||||
new StubAuthorityTokenProvider(),
|
||||
new StaticOptionsMonitor<ZastavaRuntimeOptions>(new ZastavaRuntimeOptions
|
||||
{
|
||||
Tenant = "tenant",
|
||||
Environment = "test",
|
||||
Component = "webhook",
|
||||
Authority = new ZastavaAuthorityOptions { Audience = new[] { "scanner" } },
|
||||
Logging = new ZastavaRuntimeLoggingOptions(),
|
||||
Metrics = new ZastavaRuntimeMetricsOptions()
|
||||
}),
|
||||
new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions()),
|
||||
new StubRuntimeMetrics(),
|
||||
NullLogger<RuntimePolicyClient>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<RuntimePolicyException>(() => client.EvaluateAsync(new RuntimePolicyRequest
|
||||
{
|
||||
Namespace = "payments",
|
||||
Labels = null,
|
||||
Images = new[] { "image" }
|
||||
}));
|
||||
}
|
||||
|
||||
private sealed class StubAuthorityTokenProvider : IZastavaAuthorityTokenProvider
|
||||
{
|
||||
public ValueTask InvalidateAsync(string audience, IEnumerable<string>? additionalScopes = null, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<ZastavaOperationalToken> GetAsync(string audience, IEnumerable<string>? additionalScopes = null, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new ZastavaOperationalToken("runtime-token", "DPoP", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
|
||||
}
|
||||
|
||||
private sealed class StubRuntimeMetrics : IZastavaRuntimeMetrics
|
||||
{
|
||||
public StubRuntimeMetrics()
|
||||
{
|
||||
Meter = new Meter("Test.Zastava.Webhook");
|
||||
RuntimeEvents = Meter.CreateCounter<long>("test.events");
|
||||
AdmissionDecisions = Meter.CreateCounter<long>("test.decisions");
|
||||
BackendLatencyMs = Meter.CreateHistogram<double>("test.backend.latency");
|
||||
DefaultTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
}
|
||||
|
||||
public Meter Meter { get; }
|
||||
|
||||
public Counter<long> RuntimeEvents { get; }
|
||||
|
||||
public Counter<long> AdmissionDecisions { get; }
|
||||
|
||||
public Histogram<double> BackendLatencyMs { get; }
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> DefaultTags { get; }
|
||||
|
||||
public void Dispose() => Meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Certificates;
|
||||
|
||||
public sealed class SecretFileCertificateSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadCertificate_FromPemPair_Succeeds()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=zastava-webhook", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Certificates;
|
||||
|
||||
public sealed class SecretFileCertificateSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadCertificate_FromPemPair_Succeeds()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=zastava-webhook", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var certificateWithKey = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var certificatePath = Path.GetTempFileName();
|
||||
var privateKeyPath = Path.GetTempFileName();
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(certificatePath, certificateWithKey.ExportCertificatePem());
|
||||
|
||||
var certificatePath = Path.GetTempFileName();
|
||||
var privateKeyPath = Path.GetTempFileName();
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(certificatePath, certificateWithKey.ExportCertificatePem());
|
||||
using var exportRsa = certificateWithKey.GetRSAPrivateKey() ?? throw new InvalidOperationException("Missing RSA private key");
|
||||
var privateKeyPem = PemEncoding.Write("PRIVATE KEY", exportRsa.ExportPkcs8PrivateKey());
|
||||
File.WriteAllText(privateKeyPath, privateKeyPem);
|
||||
|
||||
var source = new SecretFileCertificateSource(NullLogger<SecretFileCertificateSource>.Instance);
|
||||
var options = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
CertificatePath = certificatePath,
|
||||
PrivateKeyPath = privateKeyPath
|
||||
};
|
||||
|
||||
using var loaded = source.LoadCertificate(options);
|
||||
|
||||
Assert.Equal(certificateWithKey.Thumbprint, loaded.Thumbprint);
|
||||
Assert.NotNull(loaded.GetRSAPrivateKey());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(certificatePath);
|
||||
File.Delete(privateKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCertificate_FromPfx_Succeeds()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=zastava-webhook", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var privateKeyPem = PemEncoding.Write("PRIVATE KEY", exportRsa.ExportPkcs8PrivateKey());
|
||||
File.WriteAllText(privateKeyPath, privateKeyPem);
|
||||
|
||||
var source = new SecretFileCertificateSource(NullLogger<SecretFileCertificateSource>.Instance);
|
||||
var options = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
CertificatePath = certificatePath,
|
||||
PrivateKeyPath = privateKeyPath
|
||||
};
|
||||
|
||||
using var loaded = source.LoadCertificate(options);
|
||||
|
||||
Assert.Equal(certificateWithKey.Thumbprint, loaded.Thumbprint);
|
||||
Assert.NotNull(loaded.GetRSAPrivateKey());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(certificatePath);
|
||||
File.Delete(privateKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCertificate_FromPfx_Succeeds()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=zastava-webhook", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var certificateWithKey = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var pfxPath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var pfxBytes = certificateWithKey.Export(X509ContentType.Pfx, "test");
|
||||
File.WriteAllBytes(pfxPath, pfxBytes);
|
||||
|
||||
var source = new SecretFileCertificateSource(NullLogger<SecretFileCertificateSource>.Instance);
|
||||
var options = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
PfxPath = pfxPath,
|
||||
PfxPassword = "test"
|
||||
};
|
||||
|
||||
using var loaded = source.LoadCertificate(options);
|
||||
Assert.Equal(certificateWithKey.Thumbprint, loaded.Thumbprint);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pfxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pfxPath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var pfxBytes = certificateWithKey.Export(X509ContentType.Pfx, "test");
|
||||
File.WriteAllBytes(pfxPath, pfxBytes);
|
||||
|
||||
var source = new SecretFileCertificateSource(NullLogger<SecretFileCertificateSource>.Instance);
|
||||
var options = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
PfxPath = pfxPath,
|
||||
PfxPassword = "test"
|
||||
};
|
||||
|
||||
using var loaded = source.LoadCertificate(options);
|
||||
Assert.Equal(certificateWithKey.Thumbprint, loaded.Thumbprint);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pfxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Certificates;
|
||||
|
||||
public sealed class WebhookCertificateProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Provider_UsesMatchingSource()
|
||||
{
|
||||
var options = Options.Create(new ZastavaWebhookOptions
|
||||
{
|
||||
Tls = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
CertificatePath = "/tmp/cert.pem",
|
||||
PrivateKeyPath = "/tmp/key.pem"
|
||||
}
|
||||
});
|
||||
|
||||
var source = new ThrowingCertificateSource();
|
||||
var provider = new WebhookCertificateProvider(options, new[] { source }, NullLogger<WebhookCertificateProvider>.Instance);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => provider.GetCertificate());
|
||||
Assert.True(source.Requested);
|
||||
}
|
||||
|
||||
private sealed class ThrowingCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
public bool Requested { get; private set; }
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => true;
|
||||
|
||||
public System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
Requested = true;
|
||||
throw new InvalidOperationException("test");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Certificates;
|
||||
|
||||
public sealed class WebhookCertificateProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Provider_UsesMatchingSource()
|
||||
{
|
||||
var options = Options.Create(new ZastavaWebhookOptions
|
||||
{
|
||||
Tls = new ZastavaWebhookTlsOptions
|
||||
{
|
||||
Mode = ZastavaWebhookTlsMode.Secret,
|
||||
CertificatePath = "/tmp/cert.pem",
|
||||
PrivateKeyPath = "/tmp/key.pem"
|
||||
}
|
||||
});
|
||||
|
||||
var source = new ThrowingCertificateSource();
|
||||
var provider = new WebhookCertificateProvider(options, new[] { source }, NullLogger<WebhookCertificateProvider>.Instance);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => provider.GetCertificate());
|
||||
Assert.True(source.Requested);
|
||||
}
|
||||
|
||||
private sealed class ThrowingCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
public bool Requested { get; private set; }
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => true;
|
||||
|
||||
public System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
Requested = true;
|
||||
throw new InvalidOperationException("test");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user