up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
Vladimir Moushkov
2025-10-24 19:19:23 +03:00
parent 17d861e4ab
commit b51037a9b8
72 changed files with 6070 additions and 151 deletions

View File

@@ -0,0 +1,131 @@
using System.Linq;
using System.Text.Json;
using StellaOps.Zastava.Core.Contracts;
using StellaOps.Zastava.Webhook.Backend;
using StellaOps.Zastava.Webhook.Admission;
using Xunit;
namespace StellaOps.Zastava.Webhook.Tests.Admission;
public sealed class AdmissionResponseBuilderTests
{
[Fact]
public void Build_AllowsWhenAllDecisionsPass()
{
using var document = JsonDocument.Parse("""
{
"metadata": { "namespace": "payments" },
"spec": {
"containers": [ { "name": "api", "image": "ghcr.io/example/api:1.0" } ]
}
}
""");
var pod = document.RootElement;
var spec = pod.GetProperty("spec");
var context = new AdmissionRequestContext(
ApiVersion: "admission.k8s.io/v1",
Kind: "AdmissionReview",
Uid: "abc",
Namespace: "payments",
Labels: new Dictionary<string, string>(),
Containers: new[] { new AdmissionContainerReference("api", "ghcr.io/example/api:1.0") },
PodObject: pod,
PodSpec: spec);
var evaluation = new RuntimeAdmissionEvaluation
{
Decisions = new[]
{
new RuntimeAdmissionDecision
{
OriginalImage = "ghcr.io/example/api:1.0",
ResolvedDigest = "ghcr.io/example/api@sha256:deadbeef",
Verdict = PolicyVerdict.Pass,
Allowed = true,
Policy = new RuntimePolicyImageResult
{
PolicyVerdict = PolicyVerdict.Pass,
HasSbom = true,
Signed = true
},
Reasons = Array.Empty<string>(),
FromCache = false,
ResolutionFailed = false
}
},
BackendFailed = false,
FailOpenApplied = false,
FailureReason = null,
TtlSeconds = 300
};
var builder = new AdmissionResponseBuilder();
var (envelope, response) = builder.Build(context, evaluation);
Assert.Equal("admission.k8s.io/v1", response.ApiVersion);
Assert.True(response.Response.Allowed);
Assert.Null(response.Response.Status);
Assert.NotNull(response.Response.AuditAnnotations);
Assert.True(envelope.Decision.Images.First().HasSbomReferrers);
Assert.StartsWith("sha256-", envelope.Decision.PodSpecDigest, StringComparison.Ordinal);
}
[Fact]
public void Build_DeniedIncludesStatusAndWarnings()
{
using var document = JsonDocument.Parse("""
{
"metadata": { "namespace": "ops" },
"spec": {
"containers": [ { "name": "app", "image": "ghcr.io/example/app:latest" } ]
}
}
""");
var pod = document.RootElement;
var spec = pod.GetProperty("spec");
var context = new AdmissionRequestContext(
"admission.k8s.io/v1",
"AdmissionReview",
"uid-123",
"ops",
new Dictionary<string, string>(),
new[] { new AdmissionContainerReference("app", "ghcr.io/example/app:latest") },
pod,
spec);
var evaluation = new RuntimeAdmissionEvaluation
{
Decisions = new[]
{
new RuntimeAdmissionDecision
{
OriginalImage = "ghcr.io/example/app:latest",
ResolvedDigest = null,
Verdict = PolicyVerdict.Fail,
Allowed = false,
Policy = null,
Reasons = new[] { "policy.fail" },
FromCache = false,
ResolutionFailed = true
}
},
BackendFailed = true,
FailOpenApplied = false,
FailureReason = "backend.unavailable",
TtlSeconds = 60
};
var builder = new AdmissionResponseBuilder();
var (_, response) = builder.Build(context, evaluation);
Assert.False(response.Response.Allowed);
Assert.NotNull(response.Response.Status);
Assert.Equal(403, response.Response.Status!.Code);
Assert.NotNull(response.Response.AuditAnnotations);
Assert.Contains("zastava.stellaops/admission", response.Response.AuditAnnotations!.Keys);
}
}

View File

@@ -0,0 +1,102 @@
using System.Text.Json;
using StellaOps.Zastava.Webhook.Admission;
using Xunit;
namespace StellaOps.Zastava.Webhook.Tests.Admission;
public sealed class AdmissionReviewParserTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public void Parse_ValidRequestExtractsContainers()
{
var dto = Deserialize("""
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "abc-123",
"object": {
"metadata": {
"namespace": "payments",
"labels": { "app": "demo" }
},
"spec": {
"containers": [
{ "name": "api", "image": "ghcr.io/example/api:1.2.3" }
],
"initContainers": [
{ "name": "init", "image": "ghcr.io/example/init:1.0" }
]
}
}
}
}
""");
var parser = new AdmissionReviewParser();
var context = parser.Parse(dto);
Assert.Equal("admission.k8s.io/v1", context.ApiVersion);
Assert.Equal("AdmissionReview", context.Kind);
Assert.Equal("abc-123", context.Uid);
Assert.Equal("payments", context.Namespace);
Assert.Equal("demo", context.Labels["app"]);
Assert.Equal(2, context.Containers.Count);
Assert.Contains(context.Containers, c => c.Name == "api" && c.Image == "ghcr.io/example/api:1.2.3");
Assert.Contains(context.Containers, c => c.Name == "init" && c.Image == "ghcr.io/example/init:1.0");
}
[Fact]
public void Parse_UsesRequestNamespaceWhenAvailable()
{
var dto = Deserialize("""
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "uid-456",
"namespace": "critical",
"object": {
"metadata": {
"labels": { }
},
"spec": {
"containers": [ { "name": "app", "image": "ghcr.io/example/app:latest" } ]
}
}
}
}
""");
var parser = new AdmissionReviewParser();
var context = parser.Parse(dto);
Assert.Equal("critical", context.Namespace);
}
[Fact]
public void Parse_ThrowsWhenNoContainers()
{
var dto = Deserialize("""
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "uid-789",
"object": {
"metadata": { "namespace": "ops" },
"spec": { }
}
}
}
""");
var parser = new AdmissionReviewParser();
var ex = Assert.Throws<AdmissionReviewParseException>(() => parser.Parse(dto));
Assert.Equal("admission.review.containers", ex.Code);
}
private static AdmissionReviewRequestDto Deserialize(string json)
=> JsonSerializer.Deserialize<AdmissionReviewRequestDto>(json, SerializerOptions)!;
}

View File

@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Core.Configuration;
using StellaOps.Zastava.Core.Diagnostics;
using StellaOps.Zastava.Core.Contracts;
using StellaOps.Zastava.Webhook.Admission;
using StellaOps.Zastava.Webhook.Backend;
using StellaOps.Zastava.Webhook.Configuration;
using Xunit;
namespace StellaOps.Zastava.Webhook.Tests.Admission;
public sealed class RuntimeAdmissionPolicyServiceTests
{
private const string SampleDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
[Fact]
public async Task EvaluateAsync_UsesCacheOnSubsequentCalls()
{
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyResponse
{
TtlSeconds = 600,
Results = new Dictionary<string, RuntimePolicyImageResult>
{
[SampleDigest] = new RuntimePolicyImageResult
{
PolicyVerdict = PolicyVerdict.Pass,
Signed = true,
HasSbom = true,
Reasons = Array.Empty<string>()
}
}
});
var runtimeMetrics = new StubRuntimeMetrics();
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions());
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
var resolver = new ImageDigestResolver();
var service = new RuntimeAdmissionPolicyService(
policyClient,
resolver,
cache,
optionsMonitor,
runtimeMetrics,
timeProvider,
NullLogger<RuntimeAdmissionPolicyService>.Instance);
var request = new RuntimeAdmissionRequest(
Namespace: "payments",
Labels: new Dictionary<string, string>(),
Images: new[] { $"ghcr.io/example/api@{SampleDigest}" });
var first = await service.EvaluateAsync(request, CancellationToken.None);
Assert.Single(first.Decisions);
Assert.False(first.BackendFailed);
Assert.Equal(600, first.TtlSeconds);
Assert.Equal(1, policyClient.CallCount);
var second = await service.EvaluateAsync(request, CancellationToken.None);
Assert.Single(second.Decisions);
Assert.Equal(1, policyClient.CallCount); // no additional backend call
Assert.True(second.Decisions[0].FromCache);
Assert.Equal(300, second.TtlSeconds);
}
[Fact]
public async Task EvaluateAsync_FailOpenWhenBackendUnavailable()
{
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyException("backend", System.Net.HttpStatusCode.BadGateway));
var options = new ZastavaWebhookOptions
{
Admission = new ZastavaWebhookAdmissionOptions
{
FailOpenByDefault = false,
FailOpenNamespaces = new HashSet<string>(StringComparer.Ordinal) { "payments" }
}
};
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(options);
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
var service = new RuntimeAdmissionPolicyService(
policyClient,
new ImageDigestResolver(),
cache,
optionsMonitor,
new StubRuntimeMetrics(),
timeProvider,
NullLogger<RuntimeAdmissionPolicyService>.Instance);
var request = new RuntimeAdmissionRequest(
"payments",
new Dictionary<string, string>(),
new[] { $"ghcr.io/example/api@{SampleDigest}" });
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
Assert.True(evaluation.BackendFailed);
Assert.True(evaluation.FailOpenApplied);
Assert.Equal(300, evaluation.TtlSeconds);
var decision = Assert.Single(evaluation.Decisions);
Assert.True(decision.Allowed);
Assert.Contains("zastava.fail_open.backend_unavailable", decision.Reasons);
}
[Fact]
public async Task EvaluateAsync_FailClosedWhenNamespaceConfigured()
{
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyException("backend", System.Net.HttpStatusCode.BadGateway));
var options = new ZastavaWebhookOptions
{
Admission = new ZastavaWebhookAdmissionOptions
{
FailOpenByDefault = true,
FailClosedNamespaces = new HashSet<string>(StringComparer.Ordinal) { "critical" }
}
};
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(options);
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
var service = new RuntimeAdmissionPolicyService(
policyClient,
new ImageDigestResolver(),
cache,
optionsMonitor,
new StubRuntimeMetrics(),
timeProvider,
NullLogger<RuntimeAdmissionPolicyService>.Instance);
var request = new RuntimeAdmissionRequest(
"critical",
new Dictionary<string, string>(),
new[] { $"ghcr.io/example/api@{SampleDigest}" });
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
Assert.True(evaluation.BackendFailed);
Assert.False(evaluation.FailOpenApplied);
Assert.Equal(300, evaluation.TtlSeconds);
var decision = Assert.Single(evaluation.Decisions);
Assert.False(decision.Allowed);
Assert.Contains("zastava.backend.unavailable", decision.Reasons);
}
[Fact]
public async Task EvaluateAsync_ResolutionFailureProducesDeny()
{
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyResponse { TtlSeconds = 300 });
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions());
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
var service = new RuntimeAdmissionPolicyService(
policyClient,
new ImageDigestResolver(),
cache,
optionsMonitor,
new StubRuntimeMetrics(),
timeProvider,
NullLogger<RuntimeAdmissionPolicyService>.Instance);
var request = new RuntimeAdmissionRequest(
Namespace: "payments",
Labels: new Dictionary<string, string>(),
Images: new[] { "ghcr.io/example/api:latest" });
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal(300, evaluation.TtlSeconds);
var decision = Assert.Single(evaluation.Decisions);
Assert.False(decision.Allowed);
Assert.True(decision.ResolutionFailed);
Assert.Contains("image.reference.tag_unresolved", decision.Reasons);
}
private sealed class StubRuntimePolicyClient : IRuntimePolicyClient
{
private readonly RuntimePolicyResponse? response;
private readonly Exception? exception;
public StubRuntimePolicyClient(RuntimePolicyResponse response)
{
this.response = response;
}
public StubRuntimePolicyClient(Exception exception)
{
this.exception = exception;
}
public int CallCount { get; private set; }
public Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken)
{
CallCount++;
if (exception is not null)
{
throw exception;
}
return Task.FromResult(response ?? new RuntimePolicyResponse());
}
}
private sealed class StubRuntimeMetrics : IZastavaRuntimeMetrics
{
public StubRuntimeMetrics()
{
Meter = new Meter("Test.Zastava.Webhook");
RuntimeEvents = Meter.CreateCounter<long>("test.runtime.events");
AdmissionDecisions = Meter.CreateCounter<long>("test.admission.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 currentValue)
{
CurrentValue = currentValue;
}
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 now;
public TestTimeProvider(DateTimeOffset initial)
{
now = initial;
}
public override DateTimeOffset GetUtcNow() => now;
public void Advance(TimeSpan delta) => now = now.Add(delta);
}
}