Surface.Validation Extensibility Guide

Version: 1.0 (2025-11-28) · Audience: Scanner Worker/WebService integrators, custom analyzer developers, Zastava contributors

Overview

Surface.Validation is the pluggable validator framework that Stella Ops uses to enforce configuration and data preconditions before scanner work runs. This guide shows how to extend it with custom validators, customize reporting, and wire validation into your own components.

Quick Start

Basic Registration

// In Program.cs or your DI configuration
builder.Services.AddSurfaceValidation();

This registers the default validators:

Adding Custom Validators

builder.Services.AddSurfaceValidation(builder =>
{
    builder.AddValidator<MyCustomValidator>();
    builder.AddValidator<AnotherValidator>();
});

Writing Custom Validators

Validator Interface

Implement ISurfaceValidator to create a custom validator:

public interface ISurfaceValidator
{
    ValueTask<SurfaceValidationResult> ValidateAsync(
        SurfaceValidationContext context,
        CancellationToken cancellationToken = default);
}

Example: Registry Credentials Validator

public sealed class RegistryCredentialsValidator : ISurfaceValidator
{
    private readonly IHttpClientFactory _httpClientFactory;

    public RegistryCredentialsValidator(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async ValueTask<SurfaceValidationResult> ValidateAsync(
        SurfaceValidationContext context,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(context);

        var issues = new List<SurfaceValidationIssue>();

        // Access secrets configuration from context
        var secrets = context.Environment.Secrets;
        if (secrets.Provider == "file" && string.IsNullOrEmpty(secrets.Root))
        {
            issues.Add(SurfaceValidationIssue.Error(
                "REGISTRY_SECRETS_ROOT_MISSING",
                "Registry secrets root path is not configured.",
                "Set SCANNER_SURFACE_SECRETS_ROOT to the secrets directory."));
        }

        // Access custom properties passed during validation
        if (context.Properties.TryGetValue("registryEndpoint", out var endpoint))
        {
            var reachable = await CheckEndpointAsync(endpoint?.ToString(), cancellationToken);
            if (!reachable)
            {
                issues.Add(SurfaceValidationIssue.Warning(
                    "REGISTRY_ENDPOINT_UNREACHABLE",
                    $"Registry endpoint {endpoint} is not reachable.",
                    "Verify network connectivity to the container registry."));
            }
        }

        return issues.Count == 0
            ? SurfaceValidationResult.Success()
            : SurfaceValidationResult.FromIssues(issues);
    }

    private async Task<bool> CheckEndpointAsync(string? endpoint, CancellationToken ct)
    {
        if (string.IsNullOrEmpty(endpoint)) return true;

        try
        {
            var client = _httpClientFactory.CreateClient();
            client.Timeout = TimeSpan.FromMilliseconds(500); // Keep validations fast
            var response = await client.GetAsync(endpoint, ct);
            return response.IsSuccessStatusCode;
        }
        catch
        {
            return false;
        }
    }
}

Best Practices for Validators

  1. Keep validations fast - Target < 500ms per validator to avoid blocking startup
  2. Use appropriate severity levels:
    • Error - Fatal misconfiguration that prevents operation
    • Warning - Suboptimal configuration that may cause issues
    • Info - Informational notices
  3. Provide actionable hints - Include remediation steps in the hint parameter
  4. Access services via context - Use context.Services.GetService<T>() for DI
  5. Check cancellation tokens - Honor cancellation for async operations

Validation Context

Creating Context with Properties

var context = SurfaceValidationContext.Create(
    serviceProvider,
    componentName: "Scanner.Worker",
    environment: surfaceEnvironment,
    properties: new Dictionary<string, object?>
    {
        ["jobId"] = currentJob.Id,
        ["imageDigest"] = image.Digest,
        ["configPath"] = "/etc/scanner/config.yaml"
    });

Accessing Context in Validators

public ValueTask<SurfaceValidationResult> ValidateAsync(
    SurfaceValidationContext context,
    CancellationToken cancellationToken = default)
{
    // Access environment settings
    var endpoint = context.Environment.SurfaceFsEndpoint;
    var bucket = context.Environment.SurfaceFsBucket;
    var tenant = context.Environment.Tenant;

    // Access custom properties
    if (context.Properties.TryGetValue("imageDigest", out var digest))
    {
        // Validate specific to this image
    }

    // Access DI services
    var logger = context.Services.GetService<ILogger<MyValidator>>();
}

Running Validators

Using the Validator Runner

public class MyService
{
    private readonly ISurfaceValidatorRunner _runner;
    private readonly ISurfaceEnvironment _environment;

    public MyService(ISurfaceValidatorRunner runner, ISurfaceEnvironment environment)
    {
        _runner = runner;
        _environment = environment;
    }

    public async Task ExecuteAsync(CancellationToken ct)
    {
        var context = SurfaceValidationContext.Create(
            _serviceProvider,
            "MyService",
            _environment.Settings);

        // Option 1: Get results and handle manually
        var result = await _runner.RunAllAsync(context, ct);
        if (!result.IsSuccess)
        {
            foreach (var issue in result.Issues.Where(i => i.Severity == SurfaceValidationSeverity.Error))
            {
                _logger.LogError("Validation failed: {Code} - {Message}", issue.Code, issue.Message);
            }
            return;
        }

        // Option 2: Throw on failure (respects options)
        await _runner.EnsureAsync(context, ct);

        // Continue with work...
    }
}

Custom Reporting

Implementing a Reporter

public sealed class MetricsSurfaceValidationReporter : ISurfaceValidationReporter
{
    private readonly IMetricsFactory _metrics;

    public MetricsSurfaceValidationReporter(IMetricsFactory metrics)
    {
        _metrics = metrics;
    }

    public void Report(SurfaceValidationContext context, SurfaceValidationResult result)
    {
        var counter = _metrics.CreateCounter<long>("surface_validation_issues_total");

        foreach (var issue in result.Issues)
        {
            counter.Add(1, new KeyValuePair<string, object?>[]
            {
                new("code", issue.Code),
                new("severity", issue.Severity.ToString().ToLowerInvariant()),
                new("component", context.ComponentName)
            });
        }
    }
}

Registering Custom Reporters

// Replace default reporter
builder.Services.AddSingleton<ISurfaceValidationReporter, MetricsSurfaceValidationReporter>();

// Or add alongside default (using composite pattern)
builder.Services.Decorate<ISurfaceValidationReporter>((inner, sp) =>
    new CompositeSurfaceValidationReporter(
        inner,
        sp.GetRequiredService<MetricsSurfaceValidationReporter>()));

Configuration Options

SurfaceValidationOptions

OptionDefaultDescription
ThrowOnFailuretrueWhether EnsureAsync() throws on validation failure
ContinueOnErrorfalseWhether to continue running validators after first error

Configure via IConfiguration:

{
  "Surface": {
    "Validation": {
      "ThrowOnFailure": true,
      "ContinueOnError": false
    }
  }
}

Or programmatically:

builder.Services.Configure<SurfaceValidationOptions>(options =>
{
    options.ThrowOnFailure = true;
    options.ContinueOnError = true; // Useful for diagnostics
});

Issue Codes

Standard Codes

CodeSeverityValidator
SURFACE_ENV_MISSING_ENDPOINTErrorSurfaceEndpointValidator
SURFACE_FS_BUCKET_MISSINGErrorSurfaceEndpointValidator
SURFACE_ENV_CACHE_DIR_UNWRITABLEErrorSurfaceCacheValidator
SURFACE_ENV_CACHE_QUOTA_INVALIDErrorSurfaceCacheValidator
SURFACE_SECRET_PROVIDER_UNKNOWNErrorSurfaceSecretsValidator
SURFACE_SECRET_CONFIGURATION_MISSINGErrorSurfaceSecretsValidator
SURFACE_ENV_TENANT_MISSINGErrorSurfaceSecretsValidator

Custom Issue Codes

Follow the naming convention: <SUBSYSTEM>_<COMPONENT>_<ISSUE>

public static class MyValidationCodes
{
    public const string RegistrySecretsRootMissing = "REGISTRY_SECRETS_ROOT_MISSING";
    public const string RegistryEndpointUnreachable = "REGISTRY_ENDPOINT_UNREACHABLE";
    public const string CacheWarmupFailed = "CACHE_WARMUP_FAILED";
}

Integration Examples

Scanner Worker Startup

// In hosted service
public async Task StartAsync(CancellationToken ct)
{
    var context = SurfaceValidationContext.Create(
        _services,
        "Scanner.Worker",
        _surfaceEnv.Settings);

    try
    {
        await _validatorRunner.EnsureAsync(context, ct);
        _logger.LogInformation("Surface validation passed");
    }
    catch (SurfaceValidationException ex)
    {
        _logger.LogCritical(ex, "Surface validation failed; worker cannot start");
        throw;
    }
}

Per-Scan Validation

public async Task<ScanResult> ScanImageAsync(ImageReference image, CancellationToken ct)
{
    var context = SurfaceValidationContext.Create(
        _services,
        "Scanner.Analyzer",
        _surfaceEnv.Settings,
        new Dictionary<string, object?>
        {
            ["imageDigest"] = image.Digest,
            ["imageReference"] = image.Reference
        });

    var result = await _validatorRunner.RunAllAsync(context, ct);

    if (result.HasErrors)
    {
        return ScanResult.Failed(result.Issues.Select(i => i.Message));
    }

    // Proceed with scan...
}

Zastava Webhook Readiness

app.MapGet("/readyz", async (ISurfaceValidatorRunner runner, ISurfaceEnvironment env) =>
{
    var context = SurfaceValidationContext.Create(
        app.Services,
        "Zastava.Webhook",
        env.Settings);

    var result = await runner.RunAllAsync(context);

    if (!result.IsSuccess)
    {
        return Results.Json(new
        {
            status = "unhealthy",
            issues = result.Issues.Select(i => new { i.Code, i.Message, i.Hint })
        }, statusCode: 503);
    }

    return Results.Ok(new { status = "healthy" });
});

Testing Validators

Unit Testing

[Fact]
public async Task Validator_MissingEndpoint_ReturnsError()
{
    // Arrange
    var settings = new SurfaceEnvironmentSettings(
        SurfaceFsEndpoint: new Uri("https://surface.invalid"),
        SurfaceFsBucket: "",
        // ... other settings
    );

    var context = SurfaceValidationContext.Create(
        new ServiceCollection().BuildServiceProvider(),
        "Test",
        settings);

    var validator = new SurfaceEndpointValidator();

    // Act
    var result = await validator.ValidateAsync(context);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains(result.Issues, i => i.Code == SurfaceValidationIssueCodes.SurfaceEndpointMissing);
}

Integration Testing

[Fact]
public async Task ValidationRunner_AllValidatorsExecute()
{
    // Arrange
    var services = new ServiceCollection();
    services.AddSurfaceValidation(builder =>
    {
        builder.AddValidator<TestValidator1>();
        builder.AddValidator<TestValidator2>();
    });

    var provider = services.BuildServiceProvider();
    var runner = provider.GetRequiredService<ISurfaceValidatorRunner>();

    var context = SurfaceValidationContext.Create(
        provider,
        "IntegrationTest",
        CreateValidSettings());

    // Act
    var result = await runner.RunAllAsync(context);

    // Assert
    Assert.True(result.IsSuccess);
}

References