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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user