515 lines
20 KiB
C#
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; }
|
|
}
|