Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Registry/Distribution/PolicyPackOfflineBundleService.cs

515 lines
20 KiB
C#

// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
// Task: T7 - Policy Pack Distribution
using System.Collections.Immutable;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Registry.Distribution;
/// <summary>
/// Service for exporting and importing policy packs as offline bundles.
/// Supports air-gapped environments where OCI registries are not available.
/// </summary>
public sealed class PolicyPackOfflineBundleService
{
private const string SchemaVersion = "1.0.0";
private const string BlobsDirectory = "blobs/sha256";
private const string ManifestFile = "index.json";
private readonly ILogger<PolicyPackOfflineBundleService> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public PolicyPackOfflineBundleService(
ILogger<PolicyPackOfflineBundleService>? logger = null,
TimeProvider? timeProvider = null)
{
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PolicyPackOfflineBundleService>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Exports a policy pack to an offline bundle.
/// </summary>
public async Task<PolicyPackBundleExportResult> ExportAsync(
PolicyPackBundleExportRequest request,
string outputPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (request.PackContent.Length == 0)
{
return new PolicyPackBundleExportResult
{
Success = false,
Error = "Pack content cannot be empty"
};
}
try
{
_logger.LogInformation(
"Exporting policy pack {PackName}:{PackVersion} to {OutputPath}",
request.PackName, request.PackVersion, outputPath);
// Create temp directory for bundle layout
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var blobsDir = Path.Combine(tempDir, BlobsDirectory);
Directory.CreateDirectory(blobsDir);
try
{
var artifacts = new List<PolicyPackBundleArtifact>();
// Export main pack content
var packDigest = ComputeDigest(request.PackContent);
var packPath = Path.Combine(blobsDir, packDigest);
await File.WriteAllBytesAsync(packPath, request.PackContent, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{packDigest}",
MediaType = "application/vnd.stellaops.policy-pack.yaml.v1",
Size = request.PackContent.Length,
Path = $"{BlobsDirectory}/{packDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("stellaops.policy.pack.name", request.PackName)
.Add("stellaops.policy.pack.version", request.PackVersion)
.Add("org.opencontainers.image.title", $"{request.PackName}.yaml")
});
// Export overrides
if (request.Overrides?.Count > 0)
{
foreach (var (env, content) in request.Overrides)
{
var overrideDigest = ComputeDigest(content);
var overridePath = Path.Combine(blobsDir, overrideDigest);
await File.WriteAllBytesAsync(overridePath, content, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{overrideDigest}",
MediaType = "application/vnd.stellaops.policy-pack.override.v1+json",
Size = content.Length,
Path = $"{BlobsDirectory}/{overrideDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("stellaops.policy.pack.override.env", env)
.Add("org.opencontainers.image.title", $"overrides/{env}.yaml")
});
}
}
// Export attestation if provided
if (request.Attestation?.Length > 0)
{
var attestDigest = ComputeDigest(request.Attestation);
var attestPath = Path.Combine(blobsDir, attestDigest);
await File.WriteAllBytesAsync(attestPath, request.Attestation, cancellationToken).ConfigureAwait(false);
artifacts.Add(new PolicyPackBundleArtifact
{
Digest = $"sha256:{attestDigest}",
MediaType = "application/vnd.stellaops.policy-pack.attestation.v1+json",
Size = request.Attestation.Length,
Path = $"{BlobsDirectory}/{attestDigest}",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("org.opencontainers.image.title", "attestation.dsse.json")
});
}
// Create manifest
var manifest = new PolicyPackBundleManifest
{
SchemaVersion = SchemaVersion,
CreatedAt = _timeProvider.GetUtcNow(),
PackName = request.PackName,
PackVersion = request.PackVersion,
Artifacts = artifacts.ToImmutableArray(),
Metrics = new PolicyPackBundleMetrics
{
ArtifactCount = artifacts.Count,
OverrideCount = request.Overrides?.Count ?? 0,
HasAttestation = request.Attestation?.Length > 0,
TotalSize = artifacts.Sum(a => a.Size)
},
ManifestDigest = "" // Will be set after serialization
};
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
var manifestDigest = ComputeDigest(Encoding.UTF8.GetBytes(manifestJson));
manifest = manifest with { ManifestDigest = $"sha256:{manifestDigest}" };
manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
await File.WriteAllTextAsync(
Path.Combine(tempDir, ManifestFile),
manifestJson,
cancellationToken).ConfigureAwait(false);
// Create tar.gz
using (var fs = File.Create(outputPath))
using (var gzip = new GZipStream(fs, CompressionLevel.Optimal))
{
await CreateTarAsync(tempDir, gzip, cancellationToken).ConfigureAwait(false);
}
var bundleDigest = ComputeFileDigest(outputPath);
_logger.LogInformation(
"Bundle exported: {ArtifactCount} artifacts, {TotalSize:N0} bytes",
manifest.Metrics.ArtifactCount, manifest.Metrics.TotalSize);
return new PolicyPackBundleExportResult
{
Success = true,
BundlePath = outputPath,
BundleDigest = $"sha256:{bundleDigest}",
Metrics = manifest.Metrics
};
}
finally
{
// Cleanup temp directory
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export policy pack bundle");
return new PolicyPackBundleExportResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Imports a policy pack from an offline bundle.
/// </summary>
public async Task<PolicyPackBundleImportResult> ImportAsync(
string bundlePath,
bool verifyIntegrity = true,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
if (!File.Exists(bundlePath))
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = $"Bundle not found: {bundlePath}"
};
}
try
{
_logger.LogInformation("Importing policy pack bundle from {BundlePath}", bundlePath);
// Extract to temp directory
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-import-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
// Extract tar.gz
await using (var fs = File.OpenRead(bundlePath))
await using (var gzip = new GZipStream(fs, CompressionMode.Decompress))
{
await ExtractTarAsync(gzip, tempDir, cancellationToken).ConfigureAwait(false);
}
// Read manifest
var manifestPath = Path.Combine(tempDir, ManifestFile);
if (!File.Exists(manifestPath))
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Bundle manifest not found"
};
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<PolicyPackBundleManifest>(manifestJson, JsonOptions);
if (manifest is null)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Failed to parse bundle manifest"
};
}
// Verify integrity if requested
bool integrityVerified = false;
if (verifyIntegrity)
{
integrityVerified = await VerifyBundleIntegrityAsync(tempDir, manifest, cancellationToken)
.ConfigureAwait(false);
if (!integrityVerified)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "Bundle integrity verification failed"
};
}
}
// Read artifacts
byte[]? packContent = null;
var overrides = new Dictionary<string, byte[]>();
byte[]? attestation = null;
foreach (var artifact in manifest.Artifacts)
{
var artifactPath = Path.Combine(tempDir, artifact.Path);
if (!File.Exists(artifactPath))
{
_logger.LogWarning("Artifact not found in bundle: {Path}", artifact.Path);
continue;
}
var content = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
if (artifact.MediaType.Contains("policy-pack.yaml"))
{
packContent = content;
}
else if (artifact.MediaType.Contains("override"))
{
var env = artifact.Annotations?.GetValueOrDefault("stellaops.policy.pack.override.env");
if (!string.IsNullOrEmpty(env))
{
overrides[env] = content;
}
}
else if (artifact.MediaType.Contains("attestation"))
{
attestation = content;
}
}
if (packContent is null)
{
return new PolicyPackBundleImportResult
{
Success = false,
Error = "No policy pack content found in bundle"
};
}
_logger.LogInformation(
"Bundle imported: {PackName}:{PackVersion}, {OverrideCount} overrides",
manifest.PackName, manifest.PackVersion, overrides.Count);
return new PolicyPackBundleImportResult
{
Success = true,
PackName = manifest.PackName,
PackVersion = manifest.PackVersion,
PackContent = packContent,
Overrides = overrides.Count > 0 ? overrides : null,
Attestation = attestation,
IntegrityVerified = integrityVerified
};
}
finally
{
// Cleanup temp directory
try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ }
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import policy pack bundle from {BundlePath}", bundlePath);
return new PolicyPackBundleImportResult
{
Success = false,
Error = ex.Message
};
}
}
private async Task<bool> VerifyBundleIntegrityAsync(
string tempDir,
PolicyPackBundleManifest manifest,
CancellationToken cancellationToken)
{
foreach (var artifact in manifest.Artifacts)
{
var artifactPath = Path.Combine(tempDir, artifact.Path);
if (!File.Exists(artifactPath))
{
_logger.LogWarning("Missing artifact: {Path}", artifact.Path);
return false;
}
var data = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
var actualDigest = $"sha256:{ComputeDigest(data)}";
if (!string.Equals(actualDigest, artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Digest mismatch for {Path}: expected {Expected}, got {Actual}",
artifact.Path, artifact.Digest, actualDigest);
return false;
}
}
return true;
}
private static string ComputeDigest(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeFileDigest(string path)
{
using var fs = File.OpenRead(path);
var hash = SHA256.HashData(fs);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static async Task CreateTarAsync(string sourceDir, Stream output, CancellationToken cancellationToken)
{
// Simplified tar creation - in production, use a proper tar library
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories);
using var writer = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
foreach (var file in files)
{
var relativePath = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
// Write simple header
var header = Encoding.UTF8.GetBytes($"FILE:{relativePath}:{content.Length}\n");
writer.Write(header);
writer.Write(content);
}
}
private static async Task ExtractTarAsync(Stream input, string targetDir, CancellationToken cancellationToken)
{
// Simplified tar extraction - in production, use a proper tar library
using var memoryStream = new MemoryStream();
await input.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
memoryStream.Position = 0;
var textReader = new StreamReader(memoryStream, Encoding.UTF8, leaveOpen: true);
while (memoryStream.Position < memoryStream.Length)
{
var headerLine = textReader.ReadLine();
if (string.IsNullOrEmpty(headerLine) || !headerLine.StartsWith("FILE:"))
break;
var parts = headerLine[5..].Split(':');
if (parts.Length != 2 || !int.TryParse(parts[1], out var size))
break;
var relativePath = parts[0];
var fullPath = Path.Combine(targetDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
var content = new byte[size];
_ = memoryStream.Read(content, 0, size);
await File.WriteAllBytesAsync(fullPath, content, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Request to export a policy pack to offline bundle.
/// </summary>
public sealed record PolicyPackBundleExportRequest
{
public required string PackName { get; init; }
public required string PackVersion { get; init; }
public required byte[] PackContent { get; init; }
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
public byte[]? Attestation { get; init; }
}
/// <summary>
/// Result of policy pack bundle export.
/// </summary>
public sealed record PolicyPackBundleExportResult
{
public required bool Success { get; init; }
public string? BundlePath { get; init; }
public string? BundleDigest { get; init; }
public PolicyPackBundleMetrics? Metrics { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Result of policy pack bundle import.
/// </summary>
public sealed record PolicyPackBundleImportResult
{
public required bool Success { get; init; }
public string? PackName { get; init; }
public string? PackVersion { get; init; }
public byte[]? PackContent { get; init; }
public IReadOnlyDictionary<string, byte[]>? Overrides { get; init; }
public byte[]? Attestation { get; init; }
public bool IntegrityVerified { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Bundle manifest for policy pack.
/// </summary>
public sealed record PolicyPackBundleManifest
{
public required string SchemaVersion { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string PackName { get; init; }
public required string PackVersion { get; init; }
public required ImmutableArray<PolicyPackBundleArtifact> Artifacts { get; init; }
public required PolicyPackBundleMetrics Metrics { get; init; }
public required string ManifestDigest { get; init; }
}
/// <summary>
/// Artifact entry in bundle manifest.
/// </summary>
public sealed record PolicyPackBundleArtifact
{
public required string Digest { get; init; }
public required string MediaType { get; init; }
public required long Size { get; init; }
public required string Path { get; init; }
public ImmutableDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// Metrics about bundle contents.
/// </summary>
public sealed record PolicyPackBundleMetrics
{
public int ArtifactCount { get; init; }
public int OverrideCount { get; init; }
public bool HasAttestation { get; init; }
public long TotalSize { get; init; }
}