up
Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -0,0 +1,414 @@
# Surface.FS Workflow Guide
> **Version:** 1.0 (2025-11-28)
>
> **Audience:** Scanner Worker/WebService integrators, Zastava operators, Offline Kit builders
## Overview
Surface.FS provides a content-addressable storage layer for Scanner-derived artefacts. This guide covers the end-to-end workflow from artefact generation to consumption, including offline bundle handling.
## Workflow Stages
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Scanner Worker │───▶│ Surface.FS │───▶│ Consumers │
│ - Scan image │ │ - Store manifest│ │ - WebService │
│ - Generate │ │ - Store payload │ │ - Zastava │
│ artefacts │ │ - Local cache │ │ - CLI │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Generate: │ │ Store: │ │ Consume: │
│ - Layer frags │ │ - RustFS/S3 │ │ - Report API │
│ - EntryTrace │ │ - Local disk │ │ - Drift detect │
│ - SBOM frags │ │ - Offline kit │ │ - Rescan plan │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## Stage 1: Artefact Generation (Scanner Worker)
### 1.1 Configure Surface.FS
```csharp
// In Scanner Worker startup
builder.Services.AddSurfaceFileCache();
builder.Services.AddSurfaceManifestStore();
```
Environment variables (see [Surface.Env guide](../design/surface-env.md)):
```bash
SCANNER_SURFACE_FS_ENDPOINT=http://rustfs:8080
SCANNER_SURFACE_FS_BUCKET=surface-cache
SCANNER_SURFACE_CACHE_ROOT=/var/lib/stellaops/surface
SCANNER_SURFACE_TENANT=default
```
### 1.2 Generate and Publish Artefacts
```csharp
public async Task<ScanResult> ExecuteScanAsync(ScanJob job, CancellationToken ct)
{
// 1. Run analyzers to generate artefacts
var layerFragments = await AnalyzeLayersAsync(job.Image, ct);
var entryTrace = await AnalyzeEntryPointsAsync(job.Image, ct);
var sbomFragments = await GenerateSbomAsync(job.Image, ct);
// 2. Create manifest document
var manifest = new SurfaceManifestDocument
{
Schema = "stellaops.surface.manifest@1",
Tenant = _environment.Settings.Tenant,
ImageDigest = job.Image.Digest,
ScanId = job.Id,
GeneratedAt = DateTimeOffset.UtcNow,
Source = new SurfaceManifestSource
{
Component = "scanner.worker",
Version = _version,
WorkerInstance = Environment.MachineName,
Attempt = job.Attempt
},
Artifacts = new List<SurfaceManifestArtifact>()
};
// 3. Add artefacts to manifest
foreach (var fragment in layerFragments)
{
var payloadUri = await _manifestWriter.StorePayloadAsync(
fragment.Content,
"layer.fragments",
ct);
manifest.Artifacts.Add(new SurfaceManifestArtifact
{
Kind = "layer.fragments",
Uri = payloadUri,
Digest = fragment.Digest,
MediaType = "application/vnd.stellaops.layer-fragments+json",
Format = "json",
SizeBytes = fragment.Content.Length
});
}
// 4. Publish manifest
var result = await _manifestWriter.PublishAsync(manifest, ct);
_logger.LogInformation(
"Published manifest {Digest} with {Count} artefacts",
result.Digest,
manifest.Artifacts.Count);
return new ScanResult
{
ManifestUri = result.Uri,
ManifestDigest = result.Digest
};
}
```
### 1.3 Cache EntryTrace Results
```csharp
public async Task<EntryTraceGraph?> GetOrComputeEntryTraceAsync(
ImageReference image,
EntryTraceOptions options,
CancellationToken ct)
{
// Create deterministic cache key
var cacheKey = new SurfaceCacheKey(
@namespace: "entrytrace.graph",
tenant: _environment.Settings.Tenant,
digest: ComputeOptionsHash(options, image.Digest));
// Try cache first
var cached = await _cache.TryGetAsync<EntryTraceGraph>(cacheKey, ct);
if (cached is not null)
{
_logger.LogDebug("EntryTrace cache hit for {Key}", cacheKey);
return cached;
}
// Compute and cache
var graph = await ComputeEntryTraceAsync(image, options, ct);
await _cache.SetAsync(cacheKey, graph, ct);
return graph;
}
```
## Stage 2: Storage (Surface.FS)
### 2.1 Manifest Storage Layout
```
<bucket>/
├── manifests/
│ └── <tenant>/
│ └── <digest[0..1]>/
│ └── <digest[2..3]>/
│ └── <digest>.json
└── payloads/
└── <tenant>/
└── <kind>/
└── sha256/
└── <digest[0..1]>/
└── <digest[2..3]>/
└── <digest>.json.zst
```
### 2.2 Local Cache Layout
```
<cache_root>/
├── manifests/ # Manifest JSON files
│ └── <tenant>/...
├── cache/ # Hot artefacts
│ └── <namespace>/
│ └── <tenant>/
│ └── <digest>
└── temp/ # In-progress writes
```
### 2.3 Manifest URI Format
```
cas://<bucket>/<prefix>/<tenant>/<digest[0..1]>/<digest[2..3]>/<digest>.json
```
Example:
```
cas://surface-cache/manifests/acme/ab/cd/abcdef0123456789...json
```
## Stage 3: Consumption
### 3.1 WebService API
```http
GET /api/v1/scans/{id}
```
Response includes Surface manifest pointer:
```json
{
"id": "scan-1234",
"status": "completed",
"surface": {
"manifestUri": "cas://surface-cache/manifests/acme/ab/cd/...",
"manifestDigest": "sha256:abcdef...",
"artifacts": [
{
"kind": "layer.fragments",
"uri": "cas://surface-cache/payloads/acme/layer.fragments/...",
"digest": "sha256:123456...",
"mediaType": "application/vnd.stellaops.layer-fragments+json"
}
]
}
}
```
### 3.2 Zastava Drift Detection
```csharp
public async Task<DriftResult> DetectDriftAsync(
string imageDigest,
CancellationToken ct)
{
// 1. Fetch baseline manifest
var manifestUri = await _surfacePointerService.GetManifestUriAsync(imageDigest, ct);
var manifest = await _manifestReader.TryGetByUriAsync(manifestUri, ct);
if (manifest is null)
{
return DriftResult.NoBaseline();
}
// 2. Get EntryTrace artefact
var entryTraceArtifact = manifest.Artifacts
.FirstOrDefault(a => a.Kind == "entrytrace.graph");
if (entryTraceArtifact is null)
{
return DriftResult.NoEntryTrace();
}
// 3. Compare with runtime
var baseline = await _payloadStore.GetAsync<EntryTraceGraph>(
entryTraceArtifact.Uri, ct);
var runtime = await _runtimeCollector.CollectAsync(ct);
return CompareGraphs(baseline, runtime);
}
```
### 3.3 Scheduler Rescan Planning
```csharp
public async Task<RescanPlan> CreateRescanPlanAsync(
string imageDigest,
CancellationToken ct)
{
// 1. Read manifest to understand what was scanned
var manifest = await _manifestReader.TryGetByDigestAsync(imageDigest, ct);
if (manifest is null || IsExpired(manifest))
{
return RescanPlan.FullRescan();
}
// 2. Check for layer changes
var layerArtifact = manifest.Artifacts
.FirstOrDefault(a => a.Kind == "layer.fragments");
if (layerArtifact is not null)
{
var layers = await _payloadStore.GetAsync<LayerFragments>(
layerArtifact.Uri, ct);
var changedLayers = await DetectChangedLayersAsync(layers, ct);
if (changedLayers.Any())
{
return RescanPlan.IncrementalRescan(changedLayers);
}
}
return RescanPlan.NoRescanNeeded();
}
```
## Offline Kit Workflow
### Export (Online Environment)
```bash
# 1. Build offline kit with Surface manifests
python ops/offline-kit/build_offline_kit.py \
--version 2025.10.0 \
--include-surface-manifests \
--output-dir out/offline-kit
# 2. Kit structure includes:
# offline/
# surface/
# manifests/
# <tenant>/<digest[0..1]>/<digest[2..3]>/<digest>.json
# payloads/
# <tenant>/<kind>/sha256/<digest[0..1]>/<digest[2..3]>/<digest>.json.zst
# manifest-index.json
```
### Import (Air-Gapped Environment)
```csharp
public async Task ImportOfflineKitAsync(
string kitPath,
CancellationToken ct)
{
var surfacePath = Path.Combine(kitPath, "surface");
var indexPath = Path.Combine(surfacePath, "manifest-index.json");
var index = await LoadIndexAsync(indexPath, ct);
foreach (var entry in index.Manifests)
{
// 1. Load and verify manifest
var manifestPath = Path.Combine(surfacePath, entry.RelativePath);
var manifest = await LoadManifestAsync(manifestPath, ct);
// 2. Verify digest
var computedDigest = ComputeDigest(manifest);
if (computedDigest != entry.Digest)
{
throw new InvalidOperationException(
$"Manifest digest mismatch: expected {entry.Digest}, got {computedDigest}");
}
// 3. Import via Surface.FS API
await _manifestWriter.PublishAsync(manifest, ct);
_logger.LogInformation(
"Imported manifest {Digest} for image {Image}",
entry.Digest,
manifest.ImageDigest);
}
// 4. Import payloads
foreach (var payload in index.Payloads)
{
var payloadPath = Path.Combine(surfacePath, payload.RelativePath);
await _payloadStore.ImportAsync(payloadPath, payload.Uri, ct);
}
}
```
### Offline Operation
Once imported, Surface.FS consumers operate normally:
```csharp
// Same code works online and offline
var manifest = await _manifestReader.TryGetByUriAsync(manifestUri, ct);
var payload = await _payloadStore.GetAsync(artifact.Uri, ct);
```
## Configuration Reference
### SurfaceManifestStoreOptions
| Option | Default | Description |
|--------|---------|-------------|
| `Bucket` | `surface-cache` | Object store bucket |
| `ManifestPrefix` | `manifests` | Prefix for manifest objects |
| `PayloadPrefix` | `payloads` | Prefix for payload objects |
| `LocalManifestRoot` | `<cache>/manifests` | Local manifest directory |
### SurfaceCacheOptions
| Option | Default | Description |
|--------|---------|-------------|
| `Root` | `<temp>/stellaops/surface` | Cache root directory |
| `QuotaMegabytes` | `4096` | Cache size limit |
| `EvictionThreshold` | `0.9` | Trigger eviction at 90% quota |
## Metrics
| Metric | Labels | Description |
|--------|--------|-------------|
| `surface_manifest_published_total` | `tenant`, `kind` | Manifests published |
| `surface_manifest_cache_hit_total` | `namespace`, `tenant` | Cache hits |
| `surface_manifest_publish_duration_ms` | `tenant` | Publish latency |
| `surface_payload_persisted_total` | `kind` | Payloads stored |
## Troubleshooting
### Manifest Not Found
1. Check tenant matches between writer and reader
2. Verify Surface.FS endpoint is reachable
3. Check bucket permissions
4. Review `surface_manifest_published_total` metric
### Cache Miss Despite Expected Hit
1. Verify cache key components match (namespace, tenant, digest)
2. Check cache quota - eviction may have occurred
3. Review `surface_manifest_cache_hit_total` metric
### Offline Import Failures
1. Verify manifest digest matches index
2. Check file permissions on import path
3. Ensure Surface.FS endpoint is writable
4. Review import logs for specific errors
## References
- [Surface.FS Design](../design/surface-fs.md)
- [Surface.Env Design](../design/surface-env.md)
- [Surface.Validation Guide](./surface-validation-extensibility.md)
- [Offline Kit Documentation](../../../../24_OFFLINE_KIT.md)

View File

@@ -0,0 +1,455 @@
# Surface.Validation Extensibility Guide
> **Version:** 1.0 (2025-11-28)
>
> **Audience:** Scanner Worker/WebService integrators, custom analyzer developers, Zastava contributors
## Overview
Surface.Validation provides a pluggable validator framework for ensuring configuration and data preconditions before performing scanner work. This guide covers how to extend the validation system with custom validators, customize reporting, and integrate validation into your components.
## Quick Start
### Basic Registration
```csharp
// In Program.cs or your DI configuration
builder.Services.AddSurfaceValidation();
```
This registers the default validators:
- `SurfaceEndpointValidator` - Validates Surface.FS endpoint and bucket
- `SurfaceCacheValidator` - Validates cache directory writability and quota
- `SurfaceSecretsValidator` - Validates secrets provider configuration
### Adding Custom Validators
```csharp
builder.Services.AddSurfaceValidation(builder =>
{
builder.AddValidator<MyCustomValidator>();
builder.AddValidator<AnotherValidator>();
});
```
## Writing Custom Validators
### Validator Interface
Implement `ISurfaceValidator` to create a custom validator:
```csharp
public interface ISurfaceValidator
{
ValueTask<SurfaceValidationResult> ValidateAsync(
SurfaceValidationContext context,
CancellationToken cancellationToken = default);
}
```
### Example: Registry Credentials Validator
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
// 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
| Option | Default | Description |
|--------|---------|-------------|
| `ThrowOnFailure` | `true` | Whether `EnsureAsync()` throws on validation failure |
| `ContinueOnError` | `false` | Whether to continue running validators after first error |
Configure via `IConfiguration`:
```json
{
"Surface": {
"Validation": {
"ThrowOnFailure": true,
"ContinueOnError": false
}
}
}
```
Or programmatically:
```csharp
builder.Services.Configure<SurfaceValidationOptions>(options =>
{
options.ThrowOnFailure = true;
options.ContinueOnError = true; // Useful for diagnostics
});
```
## Issue Codes
### Standard Codes
| Code | Severity | Validator |
|------|----------|-----------|
| `SURFACE_ENV_MISSING_ENDPOINT` | Error | SurfaceEndpointValidator |
| `SURFACE_FS_BUCKET_MISSING` | Error | SurfaceEndpointValidator |
| `SURFACE_ENV_CACHE_DIR_UNWRITABLE` | Error | SurfaceCacheValidator |
| `SURFACE_ENV_CACHE_QUOTA_INVALID` | Error | SurfaceCacheValidator |
| `SURFACE_SECRET_PROVIDER_UNKNOWN` | Error | SurfaceSecretsValidator |
| `SURFACE_SECRET_CONFIGURATION_MISSING` | Error | SurfaceSecretsValidator |
| `SURFACE_ENV_TENANT_MISSING` | Error | SurfaceSecretsValidator |
### Custom Issue Codes
Follow the naming convention: `<SUBSYSTEM>_<COMPONENT>_<ISSUE>`
```csharp
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
```csharp
// 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
```csharp
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
```csharp
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
```csharp
[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
```csharp
[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
- [Surface.Validation Design](../design/surface-validation.md)
- [Surface.Env Design](../design/surface-env.md)
- [Surface.Secrets Schema](../design/surface-secrets-schema.md)