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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;
}
}
}

View File

@@ -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"
};
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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).");
}
}

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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));
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;

View File

@@ -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('/', '_');
}
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Zastava.Core.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Zastava.Core.Tests")]

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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());
}

View File

@@ -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;
});
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}
}