up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -0,0 +1,241 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Tenancy;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run log store per TASKRUN-TEN-48-001.
/// Persists logs as NDJSON under tenant-prefixed paths with tenant context propagation.
/// </summary>
public sealed class TenantScopedPackRunLogStore : IPackRunLogStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _fileLocks = new(StringComparer.Ordinal);
private readonly ILogger<TenantScopedPackRunLogStore> _logger;
public TenantScopedPackRunLogStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunLogStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
var path = GetLogsPath(runId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var gate = _fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Enrich entry with tenant context
var enrichedEntry = EnrichWithTenantContext(entry);
var document = PackRunLogEntryDocument.FromDomain(enrichedEntry);
var json = JsonSerializer.Serialize(document, SerializerOptions);
await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Appended log entry for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
}
finally
{
gate.Release();
}
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"No logs found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
yield break;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
yield break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
PackRunLogEntryDocument? document = null;
try
{
document = JsonSerializer.Deserialize<PackRunLogEntryDocument>(line, SerializerOptions);
}
catch
{
// Skip malformed entries
_logger.LogWarning("Skipping malformed log entry in run {RunId}.", runId);
}
if (document is null)
{
continue;
}
var entry = document.ToDomain();
// Verify tenant ownership from metadata
var tenantId = entry.Metadata?.GetValueOrDefault("TenantId");
if (tenantId is not null && !string.Equals(tenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"Log entry tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} in run {RunId}.",
_tenant.TenantId,
tenantId,
runId);
continue;
}
yield return entry;
}
}
public Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
return Task.FromResult(File.Exists(path));
}
private string GetLogsPath(string runId)
{
var logsPath = _pathResolver.GetLogsPath(_tenant, runId);
return $"{logsPath}.ndjson";
}
private PackRunLogEntry EnrichWithTenantContext(PackRunLogEntry entry)
{
// Add tenant context to metadata
var metadata = entry.Metadata is not null
? new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal)
: new Dictionary<string, string>(StringComparer.Ordinal);
metadata["TenantId"] = _tenant.TenantId;
metadata["ProjectId"] = _tenant.ProjectId;
return new PackRunLogEntry(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
private sealed record PackRunLogEntryDocument(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
Dictionary<string, string>? Metadata)
{
public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry)
{
var metadata = entry.Metadata is null
? null
: new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal);
return new PackRunLogEntryDocument(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
public PackRunLogEntry ToDomain()
{
IReadOnlyDictionary<string, string>? metadata = Metadata is null
? null
: new Dictionary<string, string>(Metadata, StringComparer.Ordinal);
return new PackRunLogEntry(
Timestamp,
Level,
EventType,
Message,
StepId,
metadata);
}
}
}
/// <summary>
/// Factory for creating tenant-scoped log stores.
/// </summary>
public interface ITenantScopedLogStoreFactory
{
IPackRunLogStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped log store factory.
/// </summary>
public sealed class TenantScopedLogStoreFactory : ITenantScopedLogStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedLogStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunLogStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunLogStore>();
return new TenantScopedPackRunLogStore(_pathResolver, tenant, logger);
}
}

View File

@@ -0,0 +1,282 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.Tenancy;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run state store per TASKRUN-TEN-48-001.
/// Ensures all state is stored under tenant-prefixed paths.
/// </summary>
public sealed class TenantScopedPackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly SemaphoreSlim _mutex = new(1, 1);
private readonly ILogger<TenantScopedPackRunStateStore> _logger;
private readonly string _basePath;
public TenantScopedPackRunStateStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunStateStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Use the tenant base path for listing operations
_basePath = _pathResolver.GetTenantBasePath(tenant);
Directory.CreateDirectory(_basePath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetStatePath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"State not found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
var state = document?.ToDomain();
// Validate tenant ownership
if (state is not null && !string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"State tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} for run {RunId}.",
_tenant.TenantId,
state.TenantId,
runId);
return null;
}
return state;
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
// Enforce tenant ownership
if (!string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Cannot save state for tenant {state.TenantId} in store scoped to tenant {_tenant.TenantId}.");
}
var path = GetStatePath(state.RunId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var document = StateDocument.FromDomain(state);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Saved state for run {RunId} in tenant {TenantId}.",
state.RunId,
_tenant.TenantId);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
var stateBasePath = Path.Combine(_basePath, "state");
if (!Directory.Exists(stateBasePath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
// Search recursively for state files in tenant-scoped directory
var files = Directory.EnumerateFiles(stateBasePath, "*.json", SearchOption.AllDirectories)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
var state = document.ToDomain();
// Only include states that belong to this tenant
if (string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
states.Add(state);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read state file {File}.", file);
}
}
return states;
}
private string GetStatePath(string runId)
{
var statePath = _pathResolver.GetStatePath(_tenant, runId);
return $"{statePath}.json";
}
private sealed record StateDocument(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyList<StepDocument> Steps,
string? TenantId)
{
public static StateDocument FromDomain(PackRunState state)
{
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new StateDocument(
state.RunId,
state.PlanHash,
state.Plan,
state.FailurePolicy,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
steps,
state.TenantId);
}
public PackRunState ToDomain()
{
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
Plan,
FailurePolicy,
RequestedAt,
CreatedAt,
UpdatedAt,
steps,
TenantId);
}
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}
/// <summary>
/// Factory for creating tenant-scoped state stores.
/// </summary>
public interface ITenantScopedStateStoreFactory
{
IPackRunStateStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped state store factory.
/// </summary>
public sealed class TenantScopedStateStoreFactory : ITenantScopedStateStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedStateStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunStateStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunStateStore>();
return new TenantScopedPackRunStateStore(_pathResolver, tenant, logger);
}
}