Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -3,5 +3,5 @@
|
||||
## Sprint 64 – Bundle Implementation
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| DVOFF-64-001 | TODO | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
|
||||
| DVOFF-64-001 | DOING (2025-11-04) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
|
||||
| DVOFF-64-002 | TODO | DevPortal Offline Guild, AirGap Controller Guild | DVOFF-64-001 | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. | CLI command validates signatures; integration test covers corrupted bundle; runbook updated. |
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.ExportCenter.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
public sealed class DevPortalOfflineBundleBuilder
|
||||
{
|
||||
private const string ManifestVersion = "devportal-offline/v1";
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> MediaTypeMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".html"] = "text/html",
|
||||
[".htm"] = "text/html",
|
||||
[".css"] = "text/css",
|
||||
[".js"] = "application/javascript",
|
||||
[".json"] = "application/json",
|
||||
[".yaml"] = "application/yaml",
|
||||
[".yml"] = "application/yaml",
|
||||
[".md"] = "text/markdown",
|
||||
[".txt"] = "text/plain",
|
||||
[".zip"] = "application/zip",
|
||||
[".whl"] = "application/zip",
|
||||
[".tar"] = "application/x-tar",
|
||||
[".tgz"] = "application/gzip",
|
||||
[".gz"] = "application/gzip",
|
||||
[".pdf"] = "application/pdf",
|
||||
[".svg"] = "image/svg+xml",
|
||||
[".png"] = "image/png",
|
||||
[".jpg"] = "image/jpeg",
|
||||
[".jpeg"] = "image/jpeg"
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DevPortalOfflineBundleBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public DevPortalOfflineBundleResult Build(DevPortalOfflineBundleRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.BundleId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var collected = new List<FileMetadata>();
|
||||
var sdkNames = new List<string>();
|
||||
|
||||
var portalIncluded = CollectDirectory(request.PortalDirectory, "portal", "portal", collected, cancellationToken);
|
||||
var specsIncluded = CollectDirectory(request.SpecsDirectory, "specs", "specs", collected, cancellationToken);
|
||||
|
||||
if (request.SdkSources is { Count: > 0 })
|
||||
{
|
||||
foreach (var source in request.SdkSources)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (source is null)
|
||||
{
|
||||
throw new ArgumentException("SDK sources cannot contain null entries.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.Name))
|
||||
{
|
||||
throw new ArgumentException("SDK source name cannot be empty.", nameof(request));
|
||||
}
|
||||
|
||||
var sanitizedName = SanitizeSegment(source.Name);
|
||||
sdkNames.Add(sanitizedName);
|
||||
var prefix = $"sdks/{sanitizedName}";
|
||||
CollectDirectory(source.Directory, "sdk", prefix, collected, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
var changelogIncluded = CollectDirectory(request.ChangelogDirectory, "changelog", "changelog", collected, cancellationToken);
|
||||
|
||||
if (collected.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("DevPortal offline bundle does not contain any files. Provide at least one source directory.");
|
||||
}
|
||||
|
||||
collected.Sort((left, right) => StringComparer.Ordinal.Compare(left.CanonicalPath, right.CanonicalPath));
|
||||
|
||||
var entries = new DevPortalOfflineBundleEntry[collected.Count];
|
||||
for (var i = 0; i < collected.Count; i++)
|
||||
{
|
||||
var item = collected[i];
|
||||
entries[i] = new DevPortalOfflineBundleEntry(item.Category, item.CanonicalPath, item.Sha256, item.SizeBytes, item.ContentType);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, string> metadata = request.Metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
: new Dictionary<string, string>(request.Metadata, StringComparer.Ordinal);
|
||||
|
||||
var manifest = new DevPortalOfflineBundleManifest(
|
||||
ManifestVersion,
|
||||
request.BundleId,
|
||||
_timeProvider.GetUtcNow(),
|
||||
metadata,
|
||||
new DevPortalOfflineBundleSourceSummary(
|
||||
portalIncluded,
|
||||
specsIncluded,
|
||||
sdkNames.Count == 0 ? Array.Empty<string>() : sdkNames.OrderBy(name => name, StringComparer.Ordinal).ToArray(),
|
||||
changelogIncluded),
|
||||
new DevPortalOfflineBundleTotals(entries.Length, entries.Sum(static entry => entry.SizeBytes)),
|
||||
entries);
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var rootHash = ComputeSha256(manifestJson);
|
||||
var checksums = BuildChecksums(rootHash, collected);
|
||||
var instructions = BuildInstructions(manifest);
|
||||
var verificationScript = BuildVerificationScript();
|
||||
|
||||
var bundleStream = CreatePackageStream(collected, manifestJson, checksums, instructions, verificationScript);
|
||||
bundleStream.Position = 0;
|
||||
|
||||
return new DevPortalOfflineBundleResult(manifest, manifestJson, checksums, rootHash, bundleStream);
|
||||
}
|
||||
|
||||
private static bool CollectDirectory(
|
||||
string? directory,
|
||||
string category,
|
||||
string prefix,
|
||||
ICollection<FileMetadata> collected,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(directory);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"DevPortal offline bundle source directory '{fullPath}' does not exist.");
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(fullPath, "*", SearchOption.AllDirectories);
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Array.Sort(files, StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(fullPath, file);
|
||||
var canonical = NormalizePath(prefix, relative);
|
||||
var metadata = CreateFileMetadata(category, canonical, file);
|
||||
collected.Add(metadata);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static FileMetadata CreateFileMetadata(string category, string canonicalPath, string sourcePath)
|
||||
{
|
||||
using var stream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, FileOptions.SequentialScan);
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
|
||||
long totalBytes = 0;
|
||||
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, read);
|
||||
totalBytes += read;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
return new FileMetadata(category, canonicalPath, sourcePath, totalBytes, sha, GetContentType(sourcePath));
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildChecksums(string rootHash, IReadOnlyCollection<FileMetadata> files)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# DevPortal offline bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
builder.Append(file.Sha256)
|
||||
.Append(" ")
|
||||
.AppendLine(file.CanonicalPath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInstructions(DevPortalOfflineBundleManifest manifest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("DevPortal Offline Bundle");
|
||||
builder.AppendLine("========================");
|
||||
builder.Append("Bundle ID: ").AppendLine(manifest.BundleId.ToString("D"));
|
||||
builder.Append("Generated At: ").AppendLine(manifest.GeneratedAt.ToString("O"));
|
||||
|
||||
if (manifest.Metadata.TryGetValue("releaseVersion", out var releaseVersion))
|
||||
{
|
||||
builder.Append("Release Version: ").AppendLine(releaseVersion);
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Included sections:");
|
||||
builder.Append("- Portal assets: ").AppendLine(manifest.Sources.PortalIncluded ? "yes" : "no");
|
||||
builder.Append("- Specifications: ").AppendLine(manifest.Sources.SpecsIncluded ? "yes" : "no");
|
||||
builder.Append("- SDKs: ").AppendLine(manifest.Sources.SdkNames.Count > 0
|
||||
? string.Join(", ", manifest.Sources.SdkNames)
|
||||
: "none");
|
||||
builder.Append("- Changelog: ").AppendLine(manifest.Sources.ChangelogIncluded ? "yes" : "no");
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Verification steps:");
|
||||
builder.AppendLine("1. Transfer the archive to the sealed environment.");
|
||||
builder.AppendLine("2. Execute `./verify-offline.sh devportal-offline-bundle.tgz` (or supply your filename) to extract and validate checksums.");
|
||||
builder.AppendLine("3. Run `stella devportal verify --bundle devportal-offline-bundle.tgz` to validate DSSE signatures once available.");
|
||||
builder.AppendLine("4. Review extracted changelog and README content before distributing bundles further.");
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("The manifest (`manifest.json`) lists every file with its category, size, and SHA-256 digest.");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildVerificationScript()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("set -euo pipefail");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ARCHIVE=\"${1:-devportal-offline-bundle.tgz}\"");
|
||||
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
|
||||
builder.AppendLine(" echo \"Usage: $0 <devportal-offline-bundle.tgz>\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
|
||||
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
|
||||
builder.AppendLine("trap cleanup EXIT INT TERM");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
|
||||
builder.AppendLine("echo \"DevPortal bundle extracted to $WORKDIR\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && sha256sum --check checksums.txt)");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && shasum -a 256 --check checksums.txt)");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ROOT_HASH=$(sed -n 's/\\\"rootHash\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\"]*\\)\\\"/\\1/p' \"$WORKDIR\"/manifest.json | head -n 1)");
|
||||
builder.AppendLine("echo \"Manifest root hash: ${ROOT_HASH:-unknown}\"");
|
||||
builder.AppendLine("echo \"Next: run 'stella devportal verify --bundle $ARCHIVE' for signature validation.\"");
|
||||
builder.AppendLine("echo \"Leaving extracted files in $WORKDIR for inspection.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static MemoryStream CreatePackageStream(
|
||||
IReadOnlyList<FileMetadata> files,
|
||||
string manifestJson,
|
||||
string checksums,
|
||||
string instructions,
|
||||
string verificationScript)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tar, "manifest.json", manifestJson, DefaultFileMode);
|
||||
WriteTextEntry(tar, "checksums.txt", checksums, DefaultFileMode);
|
||||
WriteTextEntry(tar, "instructions-portable.txt", instructions, DefaultFileMode);
|
||||
WriteTextEntry(tar, "verify-offline.sh", verificationScript, ExecutableFileMode);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
WriteFileEntry(tar, file);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void WriteFileEntry(TarWriter writer, FileMetadata metadata)
|
||||
{
|
||||
using var dataStream = new FileStream(metadata.SourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, FileOptions.SequentialScan);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, metadata.CanonicalPath)
|
||||
{
|
||||
Mode = metadata.CanonicalPath.EndsWith(".sh", StringComparison.Ordinal) ? ExecutableFileMode : DefaultFileMode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for devportal offline bundle.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string prefix, string relative)
|
||||
{
|
||||
var cleaned = relative.Replace('\\', '/').Trim('/');
|
||||
if (cleaned.Length == 0 || cleaned == ".")
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
if (cleaned.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Relative path '{relative}' escapes the source directory.");
|
||||
}
|
||||
|
||||
return $"{prefix}/{cleaned}";
|
||||
}
|
||||
|
||||
private static string SanitizeSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "sdk";
|
||||
}
|
||||
|
||||
var span = value.Trim();
|
||||
var builder = new StringBuilder(span.Length);
|
||||
|
||||
foreach (var ch in span)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '-' or '_' or '.')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "sdk" : builder.ToString();
|
||||
}
|
||||
|
||||
private static string? GetContentType(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
if (extension.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaTypeMap.TryGetValue(extension, out var mediaType) ? mediaType : "application/octet-stream";
|
||||
}
|
||||
|
||||
private sealed record FileMetadata(
|
||||
string Category,
|
||||
string CanonicalPath,
|
||||
string SourcePath,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
string? ContentType);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
public sealed record DevPortalOfflineBundleManifest(
|
||||
string Version,
|
||||
Guid BundleId,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
DevPortalOfflineBundleSourceSummary Sources,
|
||||
DevPortalOfflineBundleTotals Totals,
|
||||
IReadOnlyList<DevPortalOfflineBundleEntry> Entries);
|
||||
|
||||
public sealed record DevPortalOfflineBundleEntry(
|
||||
string Category,
|
||||
string Path,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? ContentType);
|
||||
|
||||
public sealed record DevPortalOfflineBundleSourceSummary(
|
||||
bool PortalIncluded,
|
||||
bool SpecsIncluded,
|
||||
IReadOnlyList<string> SdkNames,
|
||||
bool ChangelogIncluded);
|
||||
|
||||
public sealed record DevPortalOfflineBundleTotals(
|
||||
int EntryCount,
|
||||
long TotalSizeBytes);
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the source material required to build a devportal offline bundle.
|
||||
/// All directory paths are optional; when null or whitespace, the category is skipped.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Unique identifier for the generated bundle.</param>
|
||||
/// <param name="PortalDirectory">Root directory containing the portal static site assets.</param>
|
||||
/// <param name="SpecsDirectory">Root directory containing OpenAPI or other specification documents.</param>
|
||||
/// <param name="SdkSources">Optional SDK artifact sources, grouped by friendly name.</param>
|
||||
/// <param name="ChangelogDirectory">Root directory containing release notes and changelog content.</param>
|
||||
/// <param name="Metadata">Additional bundle metadata persisted in the manifest (for example release version).</param>
|
||||
public sealed record DevPortalOfflineBundleRequest(
|
||||
Guid BundleId,
|
||||
string? PortalDirectory = null,
|
||||
string? SpecsDirectory = null,
|
||||
IReadOnlyList<DevPortalSdkSource>? SdkSources = null,
|
||||
string? ChangelogDirectory = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a named SDK artifact source that should be included in the offline bundle.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical name (for example, language) used to namespace the SDK artifacts.</param>
|
||||
/// <param name="Directory">Filesystem directory that contains the artifacts to package.</param>
|
||||
public sealed record DevPortalSdkSource(string Name, string Directory);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
public sealed record DevPortalOfflineBundleResult(
|
||||
DevPortalOfflineBundleManifest Manifest,
|
||||
string ManifestJson,
|
||||
string Checksums,
|
||||
string RootHash,
|
||||
MemoryStream BundleStream);
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
public sealed record DevPortalOfflineObjectStoreOptions(
|
||||
string StorageKey,
|
||||
string ContentType);
|
||||
|
||||
public sealed record DevPortalOfflineStorageMetadata(
|
||||
string StorageKey,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public interface IDevPortalOfflineObjectStore
|
||||
{
|
||||
Task<DevPortalOfflineStorageMetadata> StoreAsync(
|
||||
Stream content,
|
||||
DevPortalOfflineObjectStoreOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ExistsAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Stream> OpenReadAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class DevPortalOfflineBundleBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ComposesExpectedArchive()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
|
||||
Directory.CreateDirectory(portalRoot);
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html>hello</html>");
|
||||
Directory.CreateDirectory(Path.Combine(portalRoot, "assets"));
|
||||
File.WriteAllText(Path.Combine(portalRoot, "assets", "app.js"), "console.log('hello');");
|
||||
|
||||
var specsRoot = Path.Combine(tempRoot.FullName, "specs");
|
||||
Directory.CreateDirectory(specsRoot);
|
||||
File.WriteAllText(Path.Combine(specsRoot, "openapi.yaml"), "openapi: 3.1.0");
|
||||
|
||||
var changelogRoot = Path.Combine(tempRoot.FullName, "changelog");
|
||||
Directory.CreateDirectory(changelogRoot);
|
||||
File.WriteAllText(Path.Combine(changelogRoot, "CHANGELOG.md"), "# Changes");
|
||||
|
||||
var sdkDotnet = Path.Combine(tempRoot.FullName, "sdk-dotnet");
|
||||
Directory.CreateDirectory(sdkDotnet);
|
||||
File.WriteAllText(Path.Combine(sdkDotnet, "stellaops.sdk.nupkg"), "dotnet sdk");
|
||||
|
||||
var sdkPython = Path.Combine(tempRoot.FullName, "sdk-python");
|
||||
Directory.CreateDirectory(sdkPython);
|
||||
File.WriteAllText(Path.Combine(sdkPython, "stellaops_sdk.whl"), "python sdk");
|
||||
|
||||
var request = new DevPortalOfflineBundleRequest(
|
||||
Guid.Parse("14b094c9-f0b4-4f9e-b221-b7a77c3f3445"),
|
||||
portalRoot,
|
||||
specsRoot,
|
||||
new[]
|
||||
{
|
||||
new DevPortalSdkSource("dotnet", sdkDotnet),
|
||||
new DevPortalSdkSource("python", sdkPython)
|
||||
},
|
||||
changelogRoot,
|
||||
new Dictionary<string, string> { ["releaseVersion"] = "2025.11.0" });
|
||||
|
||||
var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero);
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(fixedNow));
|
||||
var result = builder.Build(request);
|
||||
|
||||
Assert.Equal(request.BundleId, result.Manifest.BundleId);
|
||||
Assert.Equal("devportal-offline/v1", result.Manifest.Version);
|
||||
Assert.Equal(fixedNow, result.Manifest.GeneratedAt);
|
||||
Assert.True(result.Manifest.Sources.PortalIncluded);
|
||||
Assert.True(result.Manifest.Sources.SpecsIncluded);
|
||||
Assert.True(result.Manifest.Sources.ChangelogIncluded);
|
||||
Assert.Equal(new[] { "dotnet", "python" }, result.Manifest.Sources.SdkNames);
|
||||
Assert.Equal(6, result.Manifest.Totals.EntryCount);
|
||||
|
||||
var expectedPaths = new[]
|
||||
{
|
||||
"portal/assets/app.js",
|
||||
"portal/index.html",
|
||||
"specs/openapi.yaml",
|
||||
"sdks/dotnet/stellaops.sdk.nupkg",
|
||||
"sdks/python/stellaops_sdk.whl",
|
||||
"changelog/CHANGELOG.md"
|
||||
};
|
||||
|
||||
Assert.Equal(expectedPaths, result.Manifest.Entries.Select(entry => entry.Path).ToArray());
|
||||
|
||||
foreach (var entry in result.Manifest.Entries)
|
||||
{
|
||||
var fullPath = entry.Path switch
|
||||
{
|
||||
"portal/index.html" => Path.Combine(portalRoot, "index.html"),
|
||||
"portal/assets/app.js" => Path.Combine(portalRoot, "assets", "app.js"),
|
||||
"specs/openapi.yaml" => Path.Combine(specsRoot, "openapi.yaml"),
|
||||
"sdks/dotnet/stellaops.sdk.nupkg" => Path.Combine(sdkDotnet, "stellaops.sdk.nupkg"),
|
||||
"sdks/python/stellaops_sdk.whl" => Path.Combine(sdkPython, "stellaops_sdk.whl"),
|
||||
"changelog/CHANGELOG.md" => Path.Combine(changelogRoot, "CHANGELOG.md"),
|
||||
_ => throw new InvalidOperationException("Unexpected entry.")
|
||||
};
|
||||
|
||||
Assert.Equal(CalculateFileHash(fullPath), entry.Sha256);
|
||||
Assert.Equal(new FileInfo(fullPath).Length, entry.SizeBytes);
|
||||
}
|
||||
|
||||
Assert.Equal(CalculateTextHash(result.ManifestJson), result.RootHash);
|
||||
Assert.StartsWith("# DevPortal offline bundle checksums", result.Checksums, StringComparison.Ordinal);
|
||||
Assert.Contains("portal/assets/app.js", result.Checksums, StringComparison.Ordinal);
|
||||
|
||||
using var bundleStream = result.BundleStream;
|
||||
var bundleEntries = ExtractEntries(bundleStream);
|
||||
|
||||
Assert.Contains("manifest.json", bundleEntries.Keys);
|
||||
Assert.Contains("checksums.txt", bundleEntries.Keys);
|
||||
Assert.Contains("instructions-portable.txt", bundleEntries.Keys);
|
||||
Assert.Contains("verify-offline.sh", bundleEntries.Keys);
|
||||
|
||||
foreach (var expectedPath in expectedPaths)
|
||||
{
|
||||
Assert.Contains(expectedPath, bundleEntries.Keys);
|
||||
}
|
||||
|
||||
var instructions = Encoding.UTF8.GetString(bundleEntries["instructions-portable.txt"]);
|
||||
Assert.Contains("DevPortal Offline Bundle", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
|
||||
|
||||
var script = Encoding.UTF8.GetString(bundleEntries["verify-offline.sh"]);
|
||||
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
|
||||
Assert.Contains("stella devportal verify", script, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsWhenNoContent()
|
||||
{
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request));
|
||||
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesOptionalSources()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
|
||||
Directory.CreateDirectory(portalRoot);
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html/>");
|
||||
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot));
|
||||
|
||||
Assert.Single(result.Manifest.Entries);
|
||||
Assert.True(result.Manifest.Sources.PortalIncluded);
|
||||
Assert.False(result.Manifest.Sources.SpecsIncluded);
|
||||
Assert.False(result.Manifest.Sources.ChangelogIncluded);
|
||||
Assert.Empty(result.Manifest.Sources.SdkNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsWhenSourceDirectoryMissing()
|
||||
{
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing);
|
||||
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request));
|
||||
}
|
||||
|
||||
private static string CalculateFileHash(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string CalculateTextHash(string text)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
|
||||
|
||||
private static Dictionary<string, byte[]> ExtractEntries(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var gzip = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var reader = new TarReader(gzip);
|
||||
|
||||
var result = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
|
||||
while ((entry = reader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var memory = new MemoryStream();
|
||||
entry.DataStream.CopyTo(memory);
|
||||
result[entry.Name] = memory.ToArray();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user