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
12 KiB
12 KiB
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
// In Scanner Worker startup
builder.Services.AddSurfaceFileCache();
builder.Services.AddSurfaceManifestStore();
Environment variables (see Surface.Env guide):
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
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
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
GET /api/v1/scans/{id}
Response includes Surface manifest pointer:
{
"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
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
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)
# 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)
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:
// 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
- Check tenant matches between writer and reader
- Verify Surface.FS endpoint is reachable
- Check bucket permissions
- Review
surface_manifest_published_totalmetric
Cache Miss Despite Expected Hit
- Verify cache key components match (namespace, tenant, digest)
- Check cache quota - eviction may have occurred
- Review
surface_manifest_cache_hit_totalmetric
Offline Import Failures
- Verify manifest digest matches index
- Check file permissions on import path
- Ensure Surface.FS endpoint is writable
- Review import logs for specific errors