Implement Advisory Canonicalization and Backfill Migration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added AdvisoryCanonicalizer for canonicalizing advisory identifiers. - Created EnsureAdvisoryCanonicalKeyBackfillMigration to populate advisory_key and links in advisory_raw documents. - Introduced FileSurfaceManifestStore for managing surface manifests with file system backing. - Developed ISurfaceManifestReader and ISurfaceManifestWriter interfaces for reading and writing manifests. - Implemented SurfaceManifestPathBuilder for constructing paths and URIs for surface manifests. - Added tests for FileSurfaceManifestStore to ensure correct functionality and deterministic behavior. - Updated documentation for new features and migration steps.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS.Tests;
|
||||
|
||||
public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _root;
|
||||
private readonly FileSurfaceManifestStore _store;
|
||||
|
||||
public FileSurfaceManifestStoreTests()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("surface-fs-tests");
|
||||
|
||||
var cacheOptions = Options.Create(new SurfaceCacheOptions
|
||||
{
|
||||
RootDirectory = Path.Combine(_root.FullName, "cache")
|
||||
});
|
||||
|
||||
var manifestOptions = Options.Create(new SurfaceManifestStoreOptions
|
||||
{
|
||||
RootDirectory = Path.Combine(_root.FullName, "manifests"),
|
||||
Bucket = "test-bucket",
|
||||
Prefix = "manifests"
|
||||
});
|
||||
|
||||
_store = new FileSurfaceManifestStore(
|
||||
cacheOptions,
|
||||
manifestOptions,
|
||||
NullLogger<FileSurfaceManifestStore>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WritesManifestWithDeterministicDigest()
|
||||
{
|
||||
var doc = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "acme",
|
||||
ImageDigest = "sha256:deadbeef",
|
||||
Artifacts = new[]
|
||||
{
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "layer",
|
||||
Uri = "cas://bucket/layer",
|
||||
Digest = "sha256:aaaa",
|
||||
MediaType = "application/json",
|
||||
Format = "json",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["z"] = "last",
|
||||
["a"] = "first"
|
||||
}
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "entrytrace",
|
||||
Uri = "cas://bucket/entry",
|
||||
Digest = "sha256:bbbb",
|
||||
MediaType = "application/json",
|
||||
Format = "json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _store.PublishAsync(doc);
|
||||
|
||||
Assert.StartsWith("sha256:", result.ManifestDigest, StringComparison.Ordinal);
|
||||
Assert.Equal(result.ManifestDigest, $"sha256:{result.ManifestUri.Split('/', StringSplitOptions.RemoveEmptyEntries).Last()[..^5]}");
|
||||
Assert.NotNull(result.Document);
|
||||
Assert.True(File.Exists(GetManifestPath(result.ManifestDigest, "acme")));
|
||||
|
||||
// Metadata dictionary should be sorted to guarantee deterministic serialization
|
||||
var artifact = result.Document.Artifacts.Single(a => a.Kind == "layer");
|
||||
Assert.Equal(new[] { "a", "z" }, artifact.Metadata!.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetByUriAsync_ReturnsPublishedManifest()
|
||||
{
|
||||
var doc = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "acme",
|
||||
ScanId = "scan-123",
|
||||
Artifacts = Array.Empty<SurfaceManifestArtifact>()
|
||||
};
|
||||
|
||||
var publish = await _store.PublishAsync(doc);
|
||||
|
||||
var retrieved = await _store.TryGetByUriAsync(publish.ManifestUri);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("acme", retrieved!.Tenant);
|
||||
Assert.Equal("scan-123", retrieved.ScanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetByDigestAsync_ReturnsManifestAcrossTenants()
|
||||
{
|
||||
var doc1 = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "tenant-one",
|
||||
Artifacts = Array.Empty<SurfaceManifestArtifact>()
|
||||
};
|
||||
|
||||
var doc2 = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "tenant-two",
|
||||
Artifacts = Array.Empty<SurfaceManifestArtifact>()
|
||||
};
|
||||
|
||||
var publish1 = await _store.PublishAsync(doc1);
|
||||
var publish2 = await _store.PublishAsync(doc2);
|
||||
|
||||
var retrieved = await _store.TryGetByDigestAsync(publish2.ManifestDigest);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("tenant-two", retrieved!.Tenant);
|
||||
}
|
||||
|
||||
private string GetManifestPath(string digest, string tenant)
|
||||
{
|
||||
var hex = digest["sha256:".Length..];
|
||||
return Path.Combine(
|
||||
Path.Combine(_root.FullName, "manifests"),
|
||||
tenant,
|
||||
hex[..2],
|
||||
hex[2..4],
|
||||
$"{hex}.json");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_root.Exists)
|
||||
{
|
||||
_root.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user