feat: Add Scanner CI runner and related artifacts
- Implemented `run-scanner-ci.sh` to build and run tests for the Scanner solution with a warmed NuGet cache. - Created `excititor-vex-traces.json` dashboard for monitoring Excititor VEX observations. - Added Docker Compose configuration for the OTLP span sink in `docker-compose.spansink.yml`. - Configured OpenTelemetry collector in `otel-spansink.yaml` to receive and process traces. - Developed `run-spansink.sh` script to run the OTLP span sink for Excititor traces. - Introduced `FileSystemRiskBundleObjectStore` for storing risk bundle artifacts in the filesystem. - Built `RiskBundleBuilder` for creating risk bundles with associated metadata and providers. - Established `RiskBundleJob` to execute the risk bundle creation and storage process. - Defined models for risk bundle inputs, entries, and manifests in `RiskBundleModels.cs`. - Implemented signing functionality for risk bundle manifests with `HmacRiskBundleManifestSigner`. - Created unit tests for `RiskBundleBuilder`, `RiskBundleJob`, and signing functionality to ensure correctness. - Added filesystem artifact reader tests to validate manifest parsing and artifact listing. - Included test manifests for egress scenarios in the task runner tests. - Developed timeline query service tests to verify tenant and event ID handling.
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
- `docs/modules/export-center/mirror-bundles.md` (for 37-001/37-002)
|
||||
- `docs/modules/export-center/provenance-and-signing.md`
|
||||
- `docs/modules/export-center/operations/kms-envelope-pattern.md` (for 37-002 encryption/KMS)
|
||||
- `docs/modules/export-center/operations/risk-bundle-provider-matrix.md` (for 69/70 risk bundle chain)
|
||||
- Sprint file `docs/implplan/SPRINT_0164_0001_0001_exportcenter_iii.md`
|
||||
|
||||
## Working Agreements
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public sealed class FileSystemRiskBundleObjectStore : IRiskBundleObjectStore
|
||||
{
|
||||
private readonly IOptionsMonitor<FileSystemRiskBundleStorageOptions> _options;
|
||||
private readonly ILogger<FileSystemRiskBundleObjectStore> _logger;
|
||||
|
||||
public FileSystemRiskBundleObjectStore(
|
||||
IOptionsMonitor<FileSystemRiskBundleStorageOptions> options,
|
||||
ILogger<FileSystemRiskBundleObjectStore> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RiskBundleStorageMetadata> StoreAsync(
|
||||
RiskBundleObjectStoreOptions options,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var root = _options.CurrentValue.RootPath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new InvalidOperationException("Risk bundle storage root path is not configured.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(root, options.StorageKey);
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await using (var file = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 128 * 1024, FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await content.CopyToAsync(file, 128 * 1024, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var length = new FileInfo(fullPath).Length;
|
||||
_logger.LogInformation("Risk bundle artefact stored at {Path} ({Bytes} bytes).", fullPath, length);
|
||||
|
||||
return new RiskBundleStorageMetadata(options.StorageKey, length, options.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FileSystemRiskBundleStorageOptions
|
||||
{
|
||||
public string? RootPath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public sealed class RiskBundleBuilder
|
||||
{
|
||||
private const string ManifestVersion = "1";
|
||||
private static readonly UnixFileMode DefaultFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2024, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public RiskBundleBuildResult Build(RiskBundleBuildRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.BundleId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.Providers is not { Count: > 0 })
|
||||
{
|
||||
throw new ArgumentException("At least one provider input is required.", nameof(request));
|
||||
}
|
||||
|
||||
var providers = CollectProviders(request, cancellationToken);
|
||||
var manifest = BuildManifest(request.BundleId, providers);
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var rootHash = ComputeSha256(manifestJson);
|
||||
|
||||
var bundleStream = CreateBundleStream(
|
||||
providers,
|
||||
request.ManifestFileName,
|
||||
manifestJson,
|
||||
request.BundleFileName ?? "risk-bundle.tar.gz");
|
||||
|
||||
bundleStream.Position = 0;
|
||||
|
||||
return new RiskBundleBuildResult(manifest, manifestJson, rootHash, bundleStream);
|
||||
}
|
||||
|
||||
private static List<RiskBundleProviderEntry> CollectProviders(RiskBundleBuildRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<RiskBundleProviderEntry>(request.Providers.Count);
|
||||
|
||||
foreach (var provider in request.Providers.OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
throw new ArgumentException("Provider list cannot contain null entries.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider.ProviderId))
|
||||
{
|
||||
throw new ArgumentException("ProviderId cannot be empty.", nameof(provider.ProviderId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider.SourcePath))
|
||||
{
|
||||
throw new ArgumentException("SourcePath cannot be empty.", nameof(provider.SourcePath));
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(provider.SourcePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
if (provider.Optional && request.AllowMissingOptional)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Provider source file '{fullPath}' not found.", fullPath);
|
||||
}
|
||||
|
||||
var sha256 = ComputeSha256FromFile(fullPath);
|
||||
var size = new FileInfo(fullPath).Length;
|
||||
var bundlePath = $"providers/{provider.ProviderId}/snapshot";
|
||||
|
||||
entries.Add(new RiskBundleProviderEntry(
|
||||
provider.ProviderId,
|
||||
provider.Source,
|
||||
provider.SnapshotDate,
|
||||
sha256,
|
||||
size,
|
||||
provider.Optional,
|
||||
bundlePath,
|
||||
fullPath,
|
||||
SignaturePath: null));
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No provider artefacts collected. Provide at least one valid provider input.");
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static RiskBundleManifest BuildManifest(Guid bundleId, List<RiskBundleProviderEntry> providers)
|
||||
{
|
||||
var inputsHash = ComputeInputsHash(providers);
|
||||
return new RiskBundleManifest(
|
||||
ManifestVersion,
|
||||
bundleId,
|
||||
FixedTimestamp,
|
||||
providers.ToImmutableArray(),
|
||||
inputsHash);
|
||||
}
|
||||
|
||||
private static string ComputeInputsHash(IEnumerable<RiskBundleProviderEntry> providers)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var builder = new StringBuilder();
|
||||
foreach (var entry in providers.OrderBy(e => e.ProviderId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.ProviderId)
|
||||
.Append('\0')
|
||||
.Append(entry.Sha256)
|
||||
.Append('\0')
|
||||
.Append(entry.Source)
|
||||
.Append('\0')
|
||||
.Append(entry.Optional ? '1' : '0')
|
||||
.Append('\0')
|
||||
.Append(entry.SnapshotDate?.ToString("yyyy-MM-dd") ?? string.Empty)
|
||||
.Append('\0');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return ToHex(hash);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return ToHex(hash);
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromFile(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return ToHex(hash);
|
||||
}
|
||||
|
||||
private static string ToHex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hex = stackalloc byte[bytes.Length * 2];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
var b = bytes[i];
|
||||
hex[i * 2] = GetHexValue(b / 16);
|
||||
hex[i * 2 + 1] = GetHexValue(b % 16);
|
||||
}
|
||||
return Encoding.ASCII.GetString(hex);
|
||||
}
|
||||
|
||||
private static byte GetHexValue(int i) => (byte)(i < 10 ? i + 48 : i - 10 + 97);
|
||||
|
||||
private static MemoryStream CreateBundleStream(
|
||||
IReadOnlyList<RiskBundleProviderEntry> providers,
|
||||
string manifestFileName,
|
||||
string manifestJson,
|
||||
string bundleFileName)
|
||||
{
|
||||
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, $"manifests/{manifestFileName}", manifestJson, DefaultFileMode);
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
WriteProviderFile(tar, provider);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteProviderFile(TarWriter writer, RiskBundleProviderEntry entry)
|
||||
{
|
||||
var filePath = entry.SourceFilePath ?? throw new InvalidOperationException("Source file path missing for provider entry.");
|
||||
using var dataStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan);
|
||||
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.BundlePath)
|
||||
{
|
||||
Mode = DefaultFileMode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(tarEntry);
|
||||
}
|
||||
|
||||
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 ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for risk 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public sealed record RiskBundleJobRequest(
|
||||
RiskBundleBuildRequest BuildRequest,
|
||||
string StoragePrefix = "risk-bundles",
|
||||
string BundleFileName = "risk-bundle.tar.gz");
|
||||
|
||||
public sealed record RiskBundleJobOutcome(
|
||||
RiskBundleManifest Manifest,
|
||||
RiskBundleStorageMetadata ManifestStorage,
|
||||
RiskBundleStorageMetadata ManifestSignatureStorage,
|
||||
RiskBundleStorageMetadata BundleStorage,
|
||||
string ManifestJson,
|
||||
string ManifestSignatureJson,
|
||||
string RootHash);
|
||||
|
||||
public sealed class RiskBundleJob
|
||||
{
|
||||
private readonly RiskBundleBuilder _builder;
|
||||
private readonly IRiskBundleManifestSigner _signer;
|
||||
private readonly IRiskBundleObjectStore _objectStore;
|
||||
private readonly ILogger<RiskBundleJob> _logger;
|
||||
|
||||
public RiskBundleJob(
|
||||
RiskBundleBuilder builder,
|
||||
IRiskBundleManifestSigner signer,
|
||||
IRiskBundleObjectStore objectStore,
|
||||
ILogger<RiskBundleJob> logger)
|
||||
{
|
||||
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RiskBundleJobOutcome> ExecuteAsync(RiskBundleJobRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var build = _builder.Build(request.BuildRequest, cancellationToken);
|
||||
_logger.LogInformation("Risk bundle built with {ProviderCount} providers.", build.Manifest.Providers.Count);
|
||||
|
||||
var signature = await _signer.SignAsync(build.ManifestJson, cancellationToken).ConfigureAwait(false);
|
||||
var signatureJson = System.Text.Json.JsonSerializer.Serialize(signature, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var manifestKey = Combine(request.StoragePrefix, request.BuildRequest.ManifestFileName);
|
||||
var manifestSigKey = Combine(request.StoragePrefix, request.BuildRequest.ManifestDsseFileName);
|
||||
var bundleKey = Combine(request.StoragePrefix, request.BundleFileName);
|
||||
|
||||
var manifestStorage = await _objectStore.StoreAsync(
|
||||
new RiskBundleObjectStoreOptions(manifestKey, "application/json"),
|
||||
new MemoryStream(System.Text.Encoding.UTF8.GetBytes(build.ManifestJson)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var signatureStorage = await _objectStore.StoreAsync(
|
||||
new RiskBundleObjectStoreOptions(manifestSigKey, "application/json"),
|
||||
new MemoryStream(System.Text.Encoding.UTF8.GetBytes(signatureJson)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleStorage = await _objectStore.StoreAsync(
|
||||
new RiskBundleObjectStoreOptions(bundleKey, "application/gzip"),
|
||||
build.BundleStream,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RiskBundleJobOutcome(
|
||||
build.Manifest,
|
||||
manifestStorage,
|
||||
signatureStorage,
|
||||
bundleStorage,
|
||||
build.ManifestJson,
|
||||
signatureJson,
|
||||
build.RootHash);
|
||||
}
|
||||
|
||||
private static string Combine(string prefix, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
|
||||
var sanitizedPrefix = prefix.Trim('/');
|
||||
return string.IsNullOrWhiteSpace(sanitizedPrefix)
|
||||
? fileName
|
||||
: $"{sanitizedPrefix}/{fileName}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public sealed record RiskBundleProviderInput(
|
||||
string ProviderId,
|
||||
string SourcePath,
|
||||
string Source,
|
||||
bool Optional = false,
|
||||
DateOnly? SnapshotDate = null);
|
||||
|
||||
public sealed record RiskBundleProviderEntry(
|
||||
string ProviderId,
|
||||
string Source,
|
||||
DateOnly? SnapshotDate,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
bool Optional,
|
||||
string BundlePath,
|
||||
string SourceFilePath,
|
||||
string? SignaturePath);
|
||||
|
||||
public sealed record RiskBundleManifest(
|
||||
string Version,
|
||||
Guid BundleId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<RiskBundleProviderEntry> Providers,
|
||||
string InputsHash);
|
||||
|
||||
public sealed record RiskBundleBuildRequest(
|
||||
Guid BundleId,
|
||||
IReadOnlyList<RiskBundleProviderInput> Providers,
|
||||
string? BundleFileName = null,
|
||||
string BundlePrefix = "risk-bundles",
|
||||
string ManifestFileName = "provider-manifest.json",
|
||||
string ManifestDsseFileName = "provider-manifest.dsse",
|
||||
bool AllowMissingOptional = true);
|
||||
|
||||
public sealed record RiskBundleBuildResult(
|
||||
RiskBundleManifest Manifest,
|
||||
string ManifestJson,
|
||||
string RootHash,
|
||||
MemoryStream BundleStream);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public sealed record RiskBundleObjectStoreOptions(string StorageKey, string ContentType);
|
||||
|
||||
public sealed record RiskBundleStorageMetadata(string StorageKey, long SizeBytes, string ContentType);
|
||||
|
||||
public interface IRiskBundleObjectStore
|
||||
{
|
||||
Task<RiskBundleStorageMetadata> StoreAsync(
|
||||
RiskBundleObjectStoreOptions options,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
public interface IRiskBundleManifestSigner
|
||||
{
|
||||
Task<RiskBundleManifestSignatureDocument> SignAsync(string manifestJson, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record RiskBundleManifestSignatureDocument(
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<RiskBundleManifestDsseSignature> Signatures);
|
||||
|
||||
public sealed record RiskBundleManifestDsseSignature(
|
||||
[property: JsonPropertyName("sig")] string Signature,
|
||||
[property: JsonPropertyName("keyid")] string KeyId);
|
||||
|
||||
public sealed class HmacRiskBundleManifestSigner : IRiskBundleManifestSigner
|
||||
{
|
||||
private const string DefaultPayloadType = "application/stellaops.risk-bundle.provider-manifest+json";
|
||||
private readonly byte[] _key;
|
||||
private readonly string _keyId;
|
||||
|
||||
public HmacRiskBundleManifestSigner(string key, string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Signing key cannot be empty.", nameof(key));
|
||||
}
|
||||
|
||||
_key = Encoding.UTF8.GetBytes(key);
|
||||
_keyId = string.IsNullOrWhiteSpace(keyId) ? "risk-bundle-hmac" : keyId;
|
||||
}
|
||||
|
||||
public Task<RiskBundleManifestSignatureDocument> SignAsync(string manifestJson, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifestJson);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pae = CreatePreAuthenticationEncoding(DefaultPayloadType, manifestJson);
|
||||
var signature = ComputeHmac(pae, _key);
|
||||
|
||||
var document = new RiskBundleManifestSignatureDocument(
|
||||
DefaultPayloadType,
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson)),
|
||||
new[] { new RiskBundleManifestDsseSignature(signature, _keyId) });
|
||||
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
private static string ComputeHmac(byte[] pae, byte[] key)
|
||||
{
|
||||
using var hmac = new HMACSHA256(key);
|
||||
var signature = hmac.ComputeHash(pae);
|
||||
return Convert.ToBase64String(signature);
|
||||
}
|
||||
|
||||
private static byte[] CreatePreAuthenticationEncoding(string payloadType, string payload)
|
||||
{
|
||||
// DSSE PAE: length + type + length + payload
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var builder = new List<byte>(typeBytes.Length + payloadBytes.Length + 32);
|
||||
|
||||
AppendLength(builder, typeBytes.Length);
|
||||
builder.AddRange(typeBytes);
|
||||
AppendLength(builder, payloadBytes.Length);
|
||||
builder.AddRange(payloadBytes);
|
||||
|
||||
return builder.ToArray();
|
||||
}
|
||||
|
||||
private static void AppendLength(List<byte> buffer, int length)
|
||||
{
|
||||
var text = length.ToString(CultureInfo.InvariantCulture);
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
buffer.AddRange(bytes);
|
||||
buffer.Add(0x20); // space
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,99 +1,113 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ExportCenter", "StellaOps.ExportCenter", "{453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Core", "StellaOps.ExportCenter\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj", "{E13C1C3A-BCD1-4B32-B267-3008987833D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Infrastructure", "StellaOps.ExportCenter\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj", "{7203247A-2B03-4E9A-A8F9-E8434377A398}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Tests", "StellaOps.ExportCenter\StellaOps.ExportCenter.Tests\StellaOps.ExportCenter.Tests.csproj", "{0FF21346-59FF-4E46-953D-15C1E80B36E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.WebService", "StellaOps.ExportCenter\StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj", "{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Worker", "StellaOps.ExportCenter\StellaOps.ExportCenter.Worker\StellaOps.ExportCenter.Worker.csproj", "{77B919B8-6A4B-47BD-82BB-14287E2E069C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ExportCenter", "StellaOps.ExportCenter", "{453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Core", "StellaOps.ExportCenter\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj", "{E13C1C3A-BCD1-4B32-B267-3008987833D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Infrastructure", "StellaOps.ExportCenter\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj", "{7203247A-2B03-4E9A-A8F9-E8434377A398}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Tests", "StellaOps.ExportCenter\StellaOps.ExportCenter.Tests\StellaOps.ExportCenter.Tests.csproj", "{0FF21346-59FF-4E46-953D-15C1E80B36E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.WebService", "StellaOps.ExportCenter\StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj", "{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.Worker", "StellaOps.ExportCenter\StellaOps.ExportCenter.Worker\StellaOps.ExportCenter.Worker.csproj", "{77B919B8-6A4B-47BD-82BB-14287E2E069C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ExportCenter.RiskBundles", "StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj", "{104B6964-9935-4CF1-B759-CE0966164A9B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{104B6964-9935-4CF1-B759-CE0966164A9B}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{E13C1C3A-BCD1-4B32-B267-3008987833D9} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{7203247A-2B03-4E9A-A8F9-E8434377A398} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{0FF21346-59FF-4E46-953D-15C1E80B36E8} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{84BACF3D-19B9-4E65-A751-8EBBA39EAE5A} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
{77B919B8-6A4B-47BD-82BB-14287E2E069C} = {453E5BB8-E54E-3EF9-8B1B-5E84C5251BBC}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.IO.Compression;
|
||||
using StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class RiskBundleBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_WritesManifestAndFiles_Deterministically()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var kev = temp.WriteFile("kev.json", "{\"cve\":\"CVE-0001\"}");
|
||||
var epss = temp.WriteFile("epss.csv", "cve,score\nCVE-0001,0.12\n");
|
||||
|
||||
var request = new RiskBundleBuildRequest(
|
||||
BundleId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
||||
Providers: new[]
|
||||
{
|
||||
new RiskBundleProviderInput("cisa-kev", kev, "CISA KEV"),
|
||||
new RiskBundleProviderInput("first-epss", epss, "FIRST EPSS")
|
||||
});
|
||||
|
||||
var builder = new RiskBundleBuilder();
|
||||
var cancellation = TestContext.Current.CancellationToken;
|
||||
var result = builder.Build(request, cancellation);
|
||||
|
||||
Assert.Equal(2, result.Manifest.Providers.Count);
|
||||
Assert.Equal(new[] { "cisa-kev", "first-epss" }, result.Manifest.Providers.Select(p => p.ProviderId));
|
||||
|
||||
// Manifest hash stable
|
||||
var second = builder.Build(request, cancellation);
|
||||
Assert.Equal(result.RootHash, second.RootHash);
|
||||
|
||||
// Bundle contains manifest and provider files
|
||||
using var gzip = new GZipStream(new MemoryStream(result.BundleStream.ToArray()), CompressionMode.Decompress, leaveOpen: false);
|
||||
using var tar = new System.Formats.Tar.TarReader(gzip, leaveOpen: false);
|
||||
var entries = new List<string>();
|
||||
System.Formats.Tar.TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
entries.Add(entry.Name);
|
||||
}
|
||||
|
||||
Assert.Contains("manifests/provider-manifest.json", entries);
|
||||
Assert.Contains("providers/cisa-kev/snapshot", entries);
|
||||
Assert.Contains("providers/first-epss/snapshot", entries);
|
||||
}
|
||||
|
||||
private sealed class TempDir : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "riskbundle-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public TempDir()
|
||||
{
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string WriteFile(string name, string contents)
|
||||
{
|
||||
var full = System.IO.Path.Combine(Path, name);
|
||||
File.WriteAllText(full, contents);
|
||||
return full;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class RiskBundleJobTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_StoresManifestAndBundle()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var providerPath = temp.WriteFile("kev.json", "{}\n");
|
||||
|
||||
var buildRequest = new RiskBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Providers: new[] { new RiskBundleProviderInput("cisa-kev", providerPath, "CISA KEV") });
|
||||
|
||||
var job = new RiskBundleJob(
|
||||
new RiskBundleBuilder(),
|
||||
new HmacRiskBundleManifestSigner("secret", "risk-key"),
|
||||
new InMemoryObjectStore(),
|
||||
NullLogger<RiskBundleJob>.Instance);
|
||||
|
||||
var outcome = await job.ExecuteAsync(new RiskBundleJobRequest(buildRequest), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("risk-bundles/provider-manifest.json", outcome.ManifestStorage.StorageKey);
|
||||
Assert.Equal("risk-bundles/provider-manifest.dsse", outcome.ManifestSignatureStorage.StorageKey);
|
||||
Assert.Equal("risk-bundles/risk-bundle.tar.gz", outcome.BundleStorage.StorageKey);
|
||||
Assert.False(string.IsNullOrWhiteSpace(outcome.ManifestJson));
|
||||
Assert.False(string.IsNullOrWhiteSpace(outcome.ManifestSignatureJson));
|
||||
}
|
||||
|
||||
private sealed class InMemoryObjectStore : IRiskBundleObjectStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _store = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<RiskBundleStorageMetadata> StoreAsync(RiskBundleObjectStoreOptions options, Stream content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
content.CopyTo(ms);
|
||||
_store[options.StorageKey] = ms.ToArray();
|
||||
return Task.FromResult(new RiskBundleStorageMetadata(options.StorageKey, ms.Length, options.ContentType));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TempDir : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "riskbundle-job-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public TempDir()
|
||||
{
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string WriteFile(string name, string contents)
|
||||
{
|
||||
var full = System.IO.Path.Combine(Path, name);
|
||||
File.WriteAllText(full, contents);
|
||||
return full;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.RiskBundles;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public class RiskBundleSignerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesDsseEnvelope()
|
||||
{
|
||||
var signer = new HmacRiskBundleManifestSigner("secret-key", "test-key");
|
||||
const string manifest = "{\"foo\":1}";
|
||||
|
||||
var doc = await signer.SignAsync(manifest, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("application/stellaops.risk-bundle.provider-manifest+json", doc.PayloadType);
|
||||
Assert.NotNull(doc.Payload);
|
||||
Assert.Single(doc.Signatures);
|
||||
Assert.Equal("test-key", doc.Signatures[0].KeyId);
|
||||
|
||||
// ensure payload decodes to original manifest
|
||||
var payloadBytes = Convert.FromBase64String(doc.Payload);
|
||||
var decoded = System.Text.Encoding.UTF8.GetString(payloadBytes);
|
||||
Assert.Equal(manifest, decoded);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -116,12 +116,13 @@
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\..\StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj" />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,3 +27,4 @@ Build and operate the Source & Job Orchestrator control plane described in Epic
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
- 6. **Contract guardrails:** Pack-run scheduling now requires `projectId` plus tenant headers; reject/422 if absent. Keep OpenAPI examples and worker/CLI samples aligned. Preserve idempotency semantics (`Idempotency-Key`) and deterministic pagination/stream ordering in all APIs.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -72,6 +73,63 @@ public sealed class PackRunStreamCoordinatorTests
|
||||
Assert.Contains("event: completed", payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamWebSocketAsync_TerminalRun_SendsInitialAndCompleted()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var packRun = new PackRunDomain(
|
||||
PackRunId: Guid.NewGuid(),
|
||||
TenantId: "tenantA",
|
||||
ProjectId: null,
|
||||
PackId: "pack.demo",
|
||||
PackVersion: "1.0.0",
|
||||
Status: PackRunStatus.Succeeded,
|
||||
Priority: 0,
|
||||
Attempt: 1,
|
||||
MaxAttempts: 3,
|
||||
Parameters: "{}",
|
||||
ParametersDigest: new string('a', 64),
|
||||
IdempotencyKey: "idem-1",
|
||||
CorrelationId: null,
|
||||
LeaseId: null,
|
||||
TaskRunnerId: "runner-1",
|
||||
LeaseUntil: null,
|
||||
CreatedAt: now.AddMinutes(-2),
|
||||
ScheduledAt: now.AddMinutes(-2),
|
||||
LeasedAt: now.AddMinutes(-1),
|
||||
StartedAt: now.AddMinutes(-1),
|
||||
CompletedAt: now,
|
||||
NotBefore: null,
|
||||
Reason: null,
|
||||
ExitCode: 0,
|
||||
DurationMs: 120_000,
|
||||
CreatedBy: "tester",
|
||||
Metadata: null);
|
||||
|
||||
var logRepo = new StubPackRunLogRepository((2, 5));
|
||||
var streamOptions = Options.Create(new StreamOptions
|
||||
{
|
||||
PollInterval = TimeSpan.FromMilliseconds(150),
|
||||
HeartbeatInterval = TimeSpan.FromMilliseconds(150),
|
||||
MaxStreamDuration = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
var coordinator = new PackRunStreamCoordinator(
|
||||
new StubPackRunRepository(packRun),
|
||||
logRepo,
|
||||
streamOptions,
|
||||
TimeProvider.System,
|
||||
NullLogger<PackRunStreamCoordinator>.Instance);
|
||||
|
||||
var socket = new FakeWebSocket();
|
||||
|
||||
await coordinator.StreamWebSocketAsync(socket, packRun.TenantId, packRun, CancellationToken.None);
|
||||
|
||||
var messages = socket.SentMessages;
|
||||
Assert.Contains(messages, m => m.Contains("\"type\":\"initial\""));
|
||||
Assert.Contains(messages, m => m.Contains("\"type\":\"completed\""));
|
||||
Assert.All(messages, m => Assert.Contains(packRun.PackRunId.ToString(), m));
|
||||
}
|
||||
|
||||
private sealed class StubPackRunRepository : IPackRunRepository
|
||||
{
|
||||
private readonly PackRunDomain _packRun;
|
||||
@@ -117,4 +175,45 @@ public sealed class PackRunStreamCoordinatorTests
|
||||
=> Task.FromResult(new PackRunLogBatch(packRunId, tenantId, afterSequence, new List<PackRunLog>()));
|
||||
public Task<long> DeleteLogsAsync(string tenantId, Guid packRunId, CancellationToken cancellationToken) => Task.FromResult(0L);
|
||||
}
|
||||
|
||||
private sealed class FakeWebSocket : WebSocket
|
||||
{
|
||||
private WebSocketState _state = WebSocketState.Open;
|
||||
public List<string> SentMessages { get; } = new();
|
||||
|
||||
public override WebSocketCloseStatus? CloseStatus { get; }
|
||||
public override string? CloseStatusDescription { get; }
|
||||
public override string? SubProtocol { get; }
|
||||
public override WebSocketState State => _state;
|
||||
|
||||
public override void Abort() => _state = WebSocketState.Aborted;
|
||||
|
||||
public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
{
|
||||
_state = WebSocketState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
{
|
||||
_state = WebSocketState.CloseSent;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_state = WebSocketState.Closed;
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true));
|
||||
|
||||
public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count);
|
||||
SentMessages.Add(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public static class StreamEndpoints
|
||||
.WithName("Orchestrator_StreamPackRun")
|
||||
.WithDescription("Stream real-time pack run log and status updates via SSE");
|
||||
|
||||
group.MapGet("pack-runs/{packRunId:guid}/ws", StreamPackRunWebSocket)
|
||||
.WithName("Orchestrator_StreamPackRunWebSocket")
|
||||
.WithDescription("Stream real-time pack run log and status updates via WebSocket");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -138,4 +142,32 @@ public static class StreamEndpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamPackRunWebSocket(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packRunId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IPackRunStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Expected WebSocket request" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var packRun = await packRunRepository.GetByIdAsync(tenantId, packRunId, cancellationToken).ConfigureAwait(false);
|
||||
if (packRun is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Pack run not found" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||
await streamCoordinator.StreamWebSocketAsync(socket, tenantId, packRun, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// Enable WebSocket support for streaming endpoints
|
||||
app.UseWebSockets();
|
||||
|
||||
// OpenAPI discovery endpoints (available in all environments)
|
||||
app.MapOpenApiEndpoints();
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Orchestrator.Core.Domain;
|
||||
@@ -8,6 +10,7 @@ namespace StellaOps.Orchestrator.WebService.Streaming;
|
||||
public interface IPackRunStreamCoordinator
|
||||
{
|
||||
Task StreamAsync(HttpContext context, string tenantId, PackRun packRun, CancellationToken cancellationToken);
|
||||
Task StreamWebSocketAsync(WebSocket socket, string tenantId, PackRun packRun, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,6 +120,82 @@ public sealed class PackRunStreamCoordinator : IPackRunStreamCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StreamWebSocketAsync(WebSocket socket, string tenantId, PackRun packRun, CancellationToken cancellationToken)
|
||||
{
|
||||
if (socket is null) throw new ArgumentNullException(nameof(socket));
|
||||
|
||||
var (logCount, latestSeq) = await _logRepository.GetLogStatsAsync(tenantId, packRun.PackRunId, cancellationToken).ConfigureAwait(false);
|
||||
await SendAsync(socket, "initial", PackRunSnapshotPayload.From(packRun, logCount, latestSeq), cancellationToken).ConfigureAwait(false);
|
||||
await SendAsync(socket, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (IsTerminal(packRun.Status))
|
||||
{
|
||||
await SendCompletedAsync(socket, packRun, logCount, latestSeq, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var last = packRun;
|
||||
var lastSeq = latestSeq;
|
||||
var start = _timeProvider.GetUtcNow();
|
||||
using var poll = new PeriodicTimer(_options.PollInterval);
|
||||
using var heartbeat = new PeriodicTimer(_options.HeartbeatInterval);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() - start > _options.MaxStreamDuration)
|
||||
{
|
||||
await SendAsync(socket, "timeout", new { packRunId = last.PackRunId, reason = "Max stream duration reached" }, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
var pollTask = poll.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var hbTask = heartbeat.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var completed = await Task.WhenAny(pollTask, hbTask).ConfigureAwait(false);
|
||||
|
||||
if (completed == hbTask && await hbTask.ConfigureAwait(false))
|
||||
{
|
||||
await SendAsync(socket, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (completed == pollTask && await pollTask.ConfigureAwait(false))
|
||||
{
|
||||
var current = await _packRunRepository.GetByIdAsync(tenantId, last.PackRunId, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
await SendAsync(socket, "notFound", new NotFoundPayload(last.PackRunId.ToString(), "pack-run"), cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
var batch = await _logRepository.GetLogsAsync(tenantId, current.PackRunId, lastSeq, DefaultBatchSize, cancellationToken).ConfigureAwait(false);
|
||||
if (batch.Logs.Count > 0)
|
||||
{
|
||||
lastSeq = batch.Logs[^1].Sequence;
|
||||
await SendAsync(socket, "logs", batch.Logs.Select(PackRunLogPayload.FromDomain), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (HasStatusChanged(last, current))
|
||||
{
|
||||
await SendAsync(socket, "statusChanged", PackRunSnapshotPayload.From(current, batch.Logs.Count, lastSeq), cancellationToken).ConfigureAwait(false);
|
||||
last = current;
|
||||
|
||||
if (IsTerminal(current.Status))
|
||||
{
|
||||
await SendCompletedAsync(socket, current, batch.Logs.Count, lastSeq, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Pack run websocket stream cancelled for {PackRunId}.", packRun.PackRunId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasStatusChanged(PackRun previous, PackRun current)
|
||||
{
|
||||
return previous.Status != current.Status || previous.Attempt != current.Attempt || previous.LeaseId != current.LeaseId;
|
||||
@@ -139,8 +218,32 @@ public sealed class PackRunStreamCoordinator : IPackRunStreamCoordinator
|
||||
await SseWriter.WriteEventAsync(response, "completed", payload, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SendCompletedAsync(WebSocket socket, PackRun packRun, long logCount, long latestSequence, CancellationToken cancellationToken)
|
||||
{
|
||||
var durationSeconds = packRun.CompletedAt.HasValue && packRun.StartedAt.HasValue
|
||||
? (packRun.CompletedAt.Value - packRun.StartedAt.Value).TotalSeconds
|
||||
: packRun.CompletedAt.HasValue ? (packRun.CompletedAt.Value - packRun.CreatedAt).TotalSeconds : 0;
|
||||
|
||||
var payload = new PackRunCompletedPayload(
|
||||
PackRunId: packRun.PackRunId,
|
||||
Status: packRun.Status.ToString().ToLowerInvariant(),
|
||||
CompletedAt: packRun.CompletedAt ?? _timeProvider.GetUtcNow(),
|
||||
DurationSeconds: durationSeconds,
|
||||
LogCount: logCount,
|
||||
LatestSequence: latestSequence);
|
||||
|
||||
await SendAsync(socket, "completed", payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PackRunStatus status) =>
|
||||
status is PackRunStatus.Succeeded or PackRunStatus.Failed or PackRunStatus.Canceled or PackRunStatus.TimedOut;
|
||||
|
||||
private static async Task SendAsync(WebSocket socket, string type, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { type, data = payload }, SerializerOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(json);
|
||||
await socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PackRunSnapshotPayload(
|
||||
|
||||
@@ -2,12 +2,18 @@ using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunStepExecutor
|
||||
{
|
||||
Task<PackRunStepExecutionResult> ExecuteAsync(
|
||||
PackRunExecutionStep step,
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null);
|
||||
public interface IPackRunStepExecutor
|
||||
{
|
||||
Task<PackRunStepExecutionResult> ExecuteAsync(
|
||||
PackRunExecutionStep step,
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null)
|
||||
{
|
||||
public static PackRunStepExecutionResult Success() => new(true, null);
|
||||
|
||||
public static PackRunStepExecutionResult Failure(string error)
|
||||
=> new(false, string.IsNullOrWhiteSpace(error) ? "Unknown error" : error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Worker.Services;
|
||||
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes built-in bundle ingestion helpers: validates checksums and stages bundles to a deterministic destination.
|
||||
/// </summary>
|
||||
public sealed class BundleIngestionStepExecutor : IPackRunStepExecutor
|
||||
{
|
||||
private const string BuiltInUses = "bundle.ingest";
|
||||
private readonly string stagingRoot;
|
||||
private readonly ILogger<BundleIngestionStepExecutor> logger;
|
||||
|
||||
public BundleIngestionStepExecutor(IOptions<PackRunWorkerOptions> options, ILogger<BundleIngestionStepExecutor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
stagingRoot = Path.Combine(options.Value.ArtifactsPath, "bundles");
|
||||
Directory.CreateDirectory(stagingRoot);
|
||||
}
|
||||
|
||||
public Task<PackRunStepExecutionResult> ExecuteAsync(
|
||||
PackRunExecutionStep step,
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Non-bundle helpers are treated as no-op success for now.
|
||||
if (!IsBundleIngestStep(step))
|
||||
{
|
||||
return Task.FromResult(PackRunStepExecutionResult.Success());
|
||||
}
|
||||
|
||||
var sourcePath = GetString(parameters, "path");
|
||||
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
|
||||
{
|
||||
return Task.FromResult(PackRunStepExecutionResult.Failure("Bundle path missing or not found."));
|
||||
}
|
||||
|
||||
var checksum = GetString(parameters, "checksum") ?? GetString(parameters, "checksumSha256");
|
||||
if (!string.IsNullOrWhiteSpace(checksum))
|
||||
{
|
||||
var actual = ComputeSha256(sourcePath);
|
||||
if (!checksum.Equals(actual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(PackRunStepExecutionResult.Failure($"Checksum mismatch: expected {checksum}, actual {actual}."));
|
||||
}
|
||||
}
|
||||
|
||||
var destination = GetString(parameters, "destinationPath")
|
||||
?? Path.Combine(stagingRoot, Path.GetFileName(sourcePath));
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
File.Copy(sourcePath, destination, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to stage bundle to {Destination}.", destination);
|
||||
return Task.FromResult(PackRunStepExecutionResult.Failure("Failed to stage bundle."));
|
||||
}
|
||||
|
||||
return Task.FromResult(PackRunStepExecutionResult.Success());
|
||||
}
|
||||
|
||||
private static string? GetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
|
||||
{
|
||||
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonValue.TryGetValue<string>(out var result) ? result : null;
|
||||
}
|
||||
|
||||
private static bool IsBundleIngestStep(PackRunExecutionStep step)
|
||||
=> !string.IsNullOrWhiteSpace(step.Uses) &&
|
||||
step.Kind == PackRunStepKind.Run &&
|
||||
step.Uses.Contains(BuiltInUses, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.TaskRunner.Core.Configuration;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
@@ -36,7 +38,7 @@ public sealed class MongoPackRunArtifactReader : IPackRunArtifactReader
|
||||
doc.Status,
|
||||
doc.Notes,
|
||||
new DateTimeOffset(doc.CapturedAt, TimeSpan.Zero),
|
||||
doc.Expression?.ToJson()))
|
||||
doc.Expression?.ToJson(new JsonWriterSettings())))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.Worker.Services;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class BundleIngestionStepExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ValidBundle_CopiesAndSucceeds()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var source = Path.Combine(temp.Path, "bundle.tgz");
|
||||
await File.WriteAllTextAsync(source, "bundle-data");
|
||||
|
||||
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
|
||||
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
|
||||
|
||||
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
|
||||
{
|
||||
["path"] = Value(source),
|
||||
["checksum"] = Value("3e25960a79dbc69b674cd4ec67a72c62b3aa32b1d4d216177a5ffcc6f46673b5") // sha256 of "bundle-data"
|
||||
});
|
||||
|
||||
var result = await executor.ExecuteAsync(step, step.Parameters, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
var staged = Path.Combine(temp.Path, "bundles", "bundle.tgz");
|
||||
Assert.True(File.Exists(staged));
|
||||
Assert.Equal(await File.ReadAllBytesAsync(source), await File.ReadAllBytesAsync(staged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ChecksumMismatch_Fails()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var source = Path.Combine(temp.Path, "bundle.tgz");
|
||||
await File.WriteAllTextAsync(source, "bundle-data");
|
||||
|
||||
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
|
||||
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
|
||||
|
||||
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
|
||||
{
|
||||
["path"] = Value(source),
|
||||
["checksum"] = Value("deadbeef")
|
||||
});
|
||||
|
||||
var result = await executor.ExecuteAsync(step, step.Parameters, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains("Checksum mismatch", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UnknownUses_NoOpSuccess()
|
||||
{
|
||||
var executor = new BundleIngestionStepExecutor(
|
||||
Options.Create(new PackRunWorkerOptions { ArtifactsPath = Path.GetTempPath() }),
|
||||
NullLogger<BundleIngestionStepExecutor>.Instance);
|
||||
|
||||
var step = CreateStep("builtin:noop", new Dictionary<string, TaskPackPlanParameterValue>());
|
||||
var result = await executor.ExecuteAsync(step, step.Parameters, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
private static TaskPackPlanParameterValue Value(string literal)
|
||||
=> new(JsonValue.Create(literal), null, null, false);
|
||||
|
||||
private static PackRunExecutionStep CreateStep(string uses, IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters)
|
||||
=> new(
|
||||
id: "ingest",
|
||||
templateId: "ingest",
|
||||
kind: PackRunStepKind.Run,
|
||||
enabled: true,
|
||||
uses: uses,
|
||||
parameters: parameters,
|
||||
approvalId: null,
|
||||
gateMessage: null,
|
||||
maxParallel: null,
|
||||
continueOnError: false,
|
||||
children: PackRunExecutionStep.EmptyChildren);
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class FilesystemPackRunArtifactReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsEmpty_WhenManifestMissing()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var reader = new FilesystemPackRunArtifactReader(temp.Path);
|
||||
|
||||
var results = await reader.ListAsync("run-absent", CancellationToken.None);
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ParsesManifestAndSortsByName()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var runId = "run-1";
|
||||
var manifestPath = Path.Combine(temp.Path, "run-1", "artifact-manifest.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
RunId = runId,
|
||||
UploadedAt = DateTimeOffset.UtcNow,
|
||||
Outputs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Name = "b",
|
||||
Type = "file",
|
||||
SourcePath = (string?)"/tmp/source-b",
|
||||
StoredPath = "files/b.txt",
|
||||
Status = "copied",
|
||||
Notes = (string?)"ok",
|
||||
ExpressionJson = (string?)null
|
||||
},
|
||||
new
|
||||
{
|
||||
Name = "a",
|
||||
Type = "object",
|
||||
SourcePath = (string?)null,
|
||||
StoredPath = "expressions/a.json",
|
||||
Status = "materialized",
|
||||
Notes = (string?)null,
|
||||
ExpressionJson = "{\"key\":\"value\"}"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
await File.WriteAllTextAsync(manifestPath, json);
|
||||
|
||||
var reader = new FilesystemPackRunArtifactReader(temp.Path);
|
||||
var results = await reader.ListAsync(runId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Collection(results,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("a", first.Name);
|
||||
Assert.Equal("object", first.Type);
|
||||
Assert.Equal("expressions/a.json", first.StoredPath);
|
||||
Assert.Equal("materialized", first.Status);
|
||||
Assert.Equal("{\"key\":\"value\"}", first.ExpressionJson);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("b", second.Name);
|
||||
Assert.Equal("file", second.Type);
|
||||
Assert.Equal("files/b.txt", second.StoredPath);
|
||||
Assert.Equal("copied", second.Status);
|
||||
Assert.Null(second.ExpressionJson);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TempDir : IDisposable
|
||||
{
|
||||
public TempDir()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,25 +161,41 @@ public sealed class TaskPackPlannerTests
|
||||
Assert.Equal(30, plan.FailurePolicy.BackoffSeconds);
|
||||
Assert.False(plan.FailurePolicy.ContinueOnError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyGateHints_IncludeRuntimeMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var hints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
|
||||
Assert.Single(hints);
|
||||
var hint = hints[0];
|
||||
Assert.Equal("policy-check", hint.StepId);
|
||||
var threshold = hint.Parameters.Single(p => p.Name == "threshold");
|
||||
Assert.False(threshold.RequiresRuntimeValue);
|
||||
Assert.Null(threshold.Expression);
|
||||
var evidence = hint.Parameters.Single(p => p.Name == "evidenceRef");
|
||||
Assert.True(evidence.RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.evidence", evidence.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyGateHints_IncludeRuntimeMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var hints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
|
||||
Assert.Single(hints);
|
||||
var hint = hints[0];
|
||||
Assert.Equal("policy-check", hint.StepId);
|
||||
var threshold = hint.Parameters.Single(p => p.Name == "threshold");
|
||||
Assert.False(threshold.RequiresRuntimeValue);
|
||||
Assert.Null(threshold.Expression);
|
||||
var evidence = hint.Parameters.Single(p => p.Name == "evidenceRef");
|
||||
Assert.True(evidence.RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.evidence", evidence.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_SealedMode_BlocksUndeclaredEgress()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.EgressBlocked);
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed
|
||||
};
|
||||
var planner = new TaskPackPlanner(new EgressPolicy(options));
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WhenRequiredInputMissing_ReturnsError()
|
||||
@@ -189,7 +205,7 @@ public sealed class TaskPackPlannerTests
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Path == "inputs.sbomBundle");
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
internal static partial class TestManifests
|
||||
{
|
||||
public const string SealedEgressBlocked = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: egress-blocked
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
url: "https://example.com/data"
|
||||
egress:
|
||||
- url: "https://example.com"
|
||||
""";
|
||||
}
|
||||
@@ -3,13 +3,13 @@ using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
internal static class TestManifests
|
||||
{
|
||||
public static TaskPackManifest Load(string yaml)
|
||||
{
|
||||
var loader = new TaskPackManifestLoader();
|
||||
return loader.Deserialize(yaml);
|
||||
}
|
||||
internal static partial class TestManifests
|
||||
{
|
||||
public static TaskPackManifest Load(string yaml)
|
||||
{
|
||||
var loader = new TaskPackManifestLoader();
|
||||
return loader.Deserialize(yaml);
|
||||
}
|
||||
|
||||
public const string Sample = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
@@ -47,23 +47,25 @@ spec:
|
||||
""";
|
||||
|
||||
public const string RequiredInput = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: required-input-pack
|
||||
version: 1.2.3
|
||||
spec:
|
||||
inputs:
|
||||
- name: sbomBundle
|
||||
type: object
|
||||
required: true
|
||||
steps:
|
||||
- id: noop
|
||||
run:
|
||||
uses: builtin:noop
|
||||
""";
|
||||
|
||||
public const string StepReference = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: required-input-pack
|
||||
version: 1.2.3
|
||||
spec:
|
||||
inputs:
|
||||
- name: sbomBundle
|
||||
type: object
|
||||
required: true
|
||||
steps:
|
||||
- id: noop
|
||||
run:
|
||||
uses: builtin:noop
|
||||
with:
|
||||
sbom: "{{ inputs.sbomBundle }}"
|
||||
""";
|
||||
|
||||
public const string StepReference = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
|
||||
@@ -9,6 +9,7 @@ using MongoDB.Driver;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Configuration;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
@@ -21,8 +22,13 @@ using StellaOps.Telemetry.Core;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<TaskRunnerServiceOptions>(builder.Configuration.GetSection("TaskRunner"));
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
|
||||
builder.Services.AddSingleton<TaskPackManifestLoader>();
|
||||
builder.Services.AddSingleton<TaskPackPlanner>();
|
||||
builder.Services.AddSingleton<TaskPackPlanner>(sp =>
|
||||
{
|
||||
var egressPolicy = sp.GetRequiredService<IEgressPolicy>();
|
||||
return new TaskPackPlanner(egressPolicy);
|
||||
});
|
||||
builder.Services.AddSingleton<PackRunSimulationEngine>();
|
||||
builder.Services.AddSingleton<PackRunExecutionGraphBuilder>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -28,12 +28,13 @@
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/>
|
||||
|
||||
<ProjectReference Include="..\..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj"/>
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ builder.Services.AddSingleton<IPackRunNotificationPublisher>(sp =>
|
||||
return new LoggingPackRunNotificationPublisher(sp.GetRequiredService<ILogger<LoggingPackRunNotificationPublisher>>());
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IPackRunStepExecutor, NoopPackRunStepExecutor>();
|
||||
builder.Services.AddSingleton<IPackRunStepExecutor, BundleIngestionStepExecutor>();
|
||||
builder.Services.AddSingleton<PackRunExecutionGraphBuilder>();
|
||||
builder.Services.AddSingleton<PackRunSimulationEngine>();
|
||||
builder.Services.AddSingleton<PackRunProcessor>();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
| Task ID | Status | Sprint | Dependency | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| TASKRUN-41-001 | DONE (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | — | Implemented run API, Mongo/file stores, approvals, provenance manifest per architecture contract. |
|
||||
| TASKRUN-AIRGAP-56-001 | BLOCKED (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-41-001 | Sealed-mode plan validation; depends on 41-001. |
|
||||
| TASKRUN-AIRGAP-56-001 | DONE (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-41-001 | Sealed-mode plan validation; depends on 41-001. |
|
||||
| TASKRUN-AIRGAP-56-002 | BLOCKED (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-56-001 | Bundle ingestion helpers; depends on 56-001. |
|
||||
| TASKRUN-AIRGAP-57-001 | BLOCKED (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-56-002 | Sealed install enforcement; depends on 56-002. |
|
||||
| TASKRUN-AIRGAP-58-001 | BLOCKED (2025-11-30) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-AIRGAP-57-001 | Evidence bundles for imports; depends on 57-001. |
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
/// <summary>
|
||||
/// Query filters for timeline listing.
|
||||
/// </summary>
|
||||
public sealed class TimelineQueryOptions
|
||||
public sealed record TimelineQueryOptions
|
||||
{
|
||||
public string? EventType { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
@@ -27,4 +27,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<TimelineIndexerMigrationRunner>();
|
||||
services.AddScoped<ITimelineEventStore, TimelineEventStore>();
|
||||
services.AddScoped<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddScoped<ITimel
|
||||
services.AddScoped<ITimelineQueryStore, TimelineQueryStore>();
|
||||
services.AddScoped<ITimelineQueryService, TimelineQueryService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ public class TimelineIngestionServiceTests
|
||||
RawPayloadJson = """{"ok":true}"""
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope);
|
||||
var result = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal("sha256:8ceeb2a3cfdd5c6c0257df04e3d6b7c29c6a54f9b89e0ee1d3f3f94a639a6a39", store.LastEnvelope?.PayloadHash);
|
||||
Assert.Equal("sha256:4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", store.LastEnvelope?.PayloadHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -42,8 +42,8 @@ public class TimelineIngestionServiceTests
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
var first = await service.IngestAsync(envelope);
|
||||
var second = await service.IngestAsync(envelope);
|
||||
var first = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
var second = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(first.Inserted);
|
||||
Assert.False(second.Inserted);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
@@ -21,7 +21,7 @@ public sealed class TimelineIngestionWorkerTests
|
||||
serviceCollection.AddSingleton<ITimelineEventStore>(store);
|
||||
serviceCollection.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
serviceCollection.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
serviceCollection.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
serviceCollection.AddLogging();
|
||||
|
||||
using var host = serviceCollection.BuildServiceProvider();
|
||||
var hosted = host.GetRequiredService<IHostedService>();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class TimelineQueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_ClampsLimit()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
var options = new TimelineQueryOptions { Limit = 2000 };
|
||||
|
||||
await service.QueryAsync("tenant-a", options, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(500, store.LastOptions?.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetAsync("tenant-1", "evt-1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineQueryStore
|
||||
{
|
||||
public TimelineQueryOptions? LastOptions { get; private set; }
|
||||
public (string tenant, string id)? LastGet { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastOptions = options;
|
||||
return Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
}
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastGet = (tenantId, eventId);
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,58 +4,39 @@ namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public sealed class TimelineSchemaTests
|
||||
{
|
||||
private static string FindRepoRoot()
|
||||
private static string FindMigrationPath()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 10 && dir is not null; i++)
|
||||
for (var i = 0; i < 12 && dir is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
var candidate = Path.Combine(dir, "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return dir;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var infraCandidate = Path.Combine(dir, "StellaOps.TimelineIndexer.Infrastructure", "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(infraCandidate))
|
||||
{
|
||||
return infraCandidate;
|
||||
}
|
||||
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate repository root from test base directory.");
|
||||
throw new FileNotFoundException("Expected migration file was not found after traversing upward.", "(Db/Migrations/001_initial_schema.sql)");
|
||||
}
|
||||
|
||||
private static string ReadMigrationSql()
|
||||
{
|
||||
var root = FindRepoRoot();
|
||||
var path = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer.Infrastructure",
|
||||
"Db",
|
||||
"Migrations",
|
||||
"001_initial_schema.sql");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("Expected migration file was not found.", path);
|
||||
}
|
||||
|
||||
var path = FindMigrationPath();
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationFile_Exists()
|
||||
{
|
||||
var root = FindRepoRoot();
|
||||
var path = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer.Infrastructure",
|
||||
"Db",
|
||||
"Migrations",
|
||||
"001_initial_schema.sql");
|
||||
|
||||
var path = FindMigrationPath();
|
||||
Assert.True(File.Exists(path), $"Migration script missing at {path}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
|
||||
|
||||
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configure: options =>
|
||||
@@ -34,10 +44,64 @@ app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/timeline/events", () => Results.Ok(Array.Empty<object>()))
|
||||
app.MapGet("/timeline", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] string? correlationId,
|
||||
[FromQuery] string? traceId,
|
||||
[FromQuery] string? severity,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromQuery] long? after,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var options = new TimelineQueryOptions
|
||||
{
|
||||
EventType = eventType,
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
Severity = severity,
|
||||
Since = since,
|
||||
AfterEventSeq = after,
|
||||
Limit = limit ?? 100
|
||||
};
|
||||
var items = await service.QueryAsync(tenantId, options, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapGet("/timeline/{eventId}", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var item = await service.GetAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null ? Results.NotFound() : Results.Ok(item);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapPost("/timeline/events", () => Results.Accepted("/timeline/events", new { status = "indexed" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
|
||||
|
||||
app.Run();
|
||||
|
||||
static string GetTenantId(HttpContext ctx)
|
||||
{
|
||||
// Temporary: allow explicit header override; fallback to claim if present.
|
||||
if (ctx.Request.Headers.TryGetValue("X-Tenant", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header!;
|
||||
}
|
||||
|
||||
var tenant = ctx.User.FindFirst("tenant")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Tenant not provided");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user