up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -4,48 +4,48 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record AdvisoryIngestRequest(
|
||||
AdvisorySourceRequest Source,
|
||||
AdvisoryUpstreamRequest Upstream,
|
||||
AdvisoryContentRequest Content,
|
||||
AdvisoryIdentifiersRequest Identifiers,
|
||||
AdvisoryLinksetRequest? Linkset);
|
||||
|
||||
public sealed record AdvisorySourceRequest(
|
||||
[property: JsonPropertyName("vendor")] string Vendor,
|
||||
[property: JsonPropertyName("connector")] string Connector,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("stream")] string? Stream);
|
||||
|
||||
public sealed record AdvisoryUpstreamRequest(
|
||||
[property: JsonPropertyName("upstreamId")] string UpstreamId,
|
||||
[property: JsonPropertyName("documentVersion")] string? DocumentVersion,
|
||||
[property: JsonPropertyName("retrievedAt")] DateTimeOffset? RetrievedAt,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("signature")] AdvisorySignatureRequest Signature,
|
||||
[property: JsonPropertyName("provenance")] IDictionary<string, string>? Provenance);
|
||||
|
||||
public sealed record AdvisorySignatureRequest(
|
||||
[property: JsonPropertyName("present")] bool Present,
|
||||
[property: JsonPropertyName("format")] string? Format,
|
||||
[property: JsonPropertyName("keyId")] string? KeyId,
|
||||
[property: JsonPropertyName("sig")] string? Signature,
|
||||
[property: JsonPropertyName("certificate")] string? Certificate,
|
||||
[property: JsonPropertyName("digest")] string? Digest);
|
||||
|
||||
public sealed record AdvisoryContentRequest(
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("specVersion")] string? SpecVersion,
|
||||
[property: JsonPropertyName("raw")] JsonElement Raw,
|
||||
[property: JsonPropertyName("encoding")] string? Encoding);
|
||||
|
||||
public sealed record AdvisoryIdentifiersRequest(
|
||||
[property: JsonPropertyName("primary")] string Primary,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases);
|
||||
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record AdvisoryIngestRequest(
|
||||
AdvisorySourceRequest Source,
|
||||
AdvisoryUpstreamRequest Upstream,
|
||||
AdvisoryContentRequest Content,
|
||||
AdvisoryIdentifiersRequest Identifiers,
|
||||
AdvisoryLinksetRequest? Linkset);
|
||||
|
||||
public sealed record AdvisorySourceRequest(
|
||||
[property: JsonPropertyName("vendor")] string Vendor,
|
||||
[property: JsonPropertyName("connector")] string Connector,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("stream")] string? Stream);
|
||||
|
||||
public sealed record AdvisoryUpstreamRequest(
|
||||
[property: JsonPropertyName("upstreamId")] string UpstreamId,
|
||||
[property: JsonPropertyName("documentVersion")] string? DocumentVersion,
|
||||
[property: JsonPropertyName("retrievedAt")] DateTimeOffset? RetrievedAt,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("signature")] AdvisorySignatureRequest Signature,
|
||||
[property: JsonPropertyName("provenance")] IDictionary<string, string>? Provenance);
|
||||
|
||||
public sealed record AdvisorySignatureRequest(
|
||||
[property: JsonPropertyName("present")] bool Present,
|
||||
[property: JsonPropertyName("format")] string? Format,
|
||||
[property: JsonPropertyName("keyId")] string? KeyId,
|
||||
[property: JsonPropertyName("sig")] string? Signature,
|
||||
[property: JsonPropertyName("certificate")] string? Certificate,
|
||||
[property: JsonPropertyName("digest")] string? Digest);
|
||||
|
||||
public sealed record AdvisoryContentRequest(
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("specVersion")] string? SpecVersion,
|
||||
[property: JsonPropertyName("raw")] JsonElement Raw,
|
||||
[property: JsonPropertyName("encoding")] string? Encoding);
|
||||
|
||||
public sealed record AdvisoryIdentifiersRequest(
|
||||
[property: JsonPropertyName("primary")] string Primary,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases);
|
||||
|
||||
public sealed record AdvisoryLinksetRequest(
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string>? Scopes,
|
||||
@@ -66,23 +66,23 @@ public sealed record AdvisoryLinksetReferenceRequest(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("source")] string? Source);
|
||||
|
||||
public sealed record AdvisoryIngestResponse(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("inserted")] bool Inserted,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdvisoryRawRecordResponse(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("document")] AdvisoryRawDocument Document);
|
||||
|
||||
|
||||
public sealed record AdvisoryIngestResponse(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("inserted")] bool Inserted,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdvisoryRawRecordResponse(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("document")] AdvisoryRawDocument Document);
|
||||
|
||||
public sealed record AdvisoryRawListResponse(
|
||||
[property: JsonPropertyName("records")] IReadOnlyList<AdvisoryRawRecordResponse> Records,
|
||||
[property: JsonPropertyName("nextCursor")] string? NextCursor,
|
||||
@@ -98,44 +98,44 @@ public sealed record AdvisoryRawProvenanceResponse(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("source")] RawSourceMetadata Source,
|
||||
[property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream,
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AocVerifyRequest(
|
||||
[property: JsonPropertyName("since")] DateTimeOffset? Since,
|
||||
[property: JsonPropertyName("until")] DateTimeOffset? Until,
|
||||
[property: JsonPropertyName("limit")] int? Limit,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources,
|
||||
[property: JsonPropertyName("codes")] IReadOnlyList<string>? Codes);
|
||||
|
||||
public sealed record AocVerifyResponse(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("window")] AocVerifyWindow Window,
|
||||
[property: JsonPropertyName("checked")] AocVerifyChecked Checked,
|
||||
[property: JsonPropertyName("violations")] IReadOnlyList<AocVerifyViolation> Violations,
|
||||
[property: JsonPropertyName("metrics")] AocVerifyMetrics Metrics,
|
||||
[property: JsonPropertyName("truncated")] bool Truncated);
|
||||
|
||||
public sealed record AocVerifyWindow(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset From,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset To);
|
||||
|
||||
public sealed record AocVerifyChecked(
|
||||
[property: JsonPropertyName("advisories")] int Advisories,
|
||||
[property: JsonPropertyName("vex")] int Vex);
|
||||
|
||||
public sealed record AocVerifyMetrics(
|
||||
[property: JsonPropertyName("ingestion_write_total")] int IngestionWriteTotal,
|
||||
[property: JsonPropertyName("aoc_violation_total")] int AocViolationTotal);
|
||||
|
||||
public sealed record AocVerifyViolation(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("examples")] IReadOnlyList<AocVerifyViolationExample> Examples);
|
||||
|
||||
public sealed record AocVerifyViolationExample(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("documentId")] string DocumentId,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("path")] string Path);
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AocVerifyRequest(
|
||||
[property: JsonPropertyName("since")] DateTimeOffset? Since,
|
||||
[property: JsonPropertyName("until")] DateTimeOffset? Until,
|
||||
[property: JsonPropertyName("limit")] int? Limit,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources,
|
||||
[property: JsonPropertyName("codes")] IReadOnlyList<string>? Codes);
|
||||
|
||||
public sealed record AocVerifyResponse(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("window")] AocVerifyWindow Window,
|
||||
[property: JsonPropertyName("checked")] AocVerifyChecked Checked,
|
||||
[property: JsonPropertyName("violations")] IReadOnlyList<AocVerifyViolation> Violations,
|
||||
[property: JsonPropertyName("metrics")] AocVerifyMetrics Metrics,
|
||||
[property: JsonPropertyName("truncated")] bool Truncated);
|
||||
|
||||
public sealed record AocVerifyWindow(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset From,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset To);
|
||||
|
||||
public sealed record AocVerifyChecked(
|
||||
[property: JsonPropertyName("advisories")] int Advisories,
|
||||
[property: JsonPropertyName("vex")] int Vex);
|
||||
|
||||
public sealed record AocVerifyMetrics(
|
||||
[property: JsonPropertyName("ingestion_write_total")] int IngestionWriteTotal,
|
||||
[property: JsonPropertyName("aoc_violation_total")] int AocViolationTotal);
|
||||
|
||||
public sealed record AocVerifyViolation(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("examples")] IReadOnlyList<AocVerifyViolationExample> Examples);
|
||||
|
||||
public sealed record AocVerifyViolationExample(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("documentId")] string DocumentId,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("path")] string Path);
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal sealed record StorageHealth(
|
||||
string Backend,
|
||||
bool Ready,
|
||||
DateTimeOffset? CheckedAt,
|
||||
double? LatencyMs,
|
||||
string? Error);
|
||||
|
||||
internal sealed record TelemetryHealth(
|
||||
bool Enabled,
|
||||
bool Tracing,
|
||||
bool Metrics,
|
||||
bool Logging);
|
||||
|
||||
internal sealed record HealthDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
double UptimeSeconds,
|
||||
StorageHealth Storage,
|
||||
TelemetryHealth Telemetry);
|
||||
|
||||
internal sealed record ReadyDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
double UptimeSeconds,
|
||||
StorageHealth Storage);
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal sealed record StorageHealth(
|
||||
string Backend,
|
||||
bool Ready,
|
||||
DateTimeOffset? CheckedAt,
|
||||
double? LatencyMs,
|
||||
string? Error);
|
||||
|
||||
internal sealed record TelemetryHealth(
|
||||
bool Enabled,
|
||||
bool Tracing,
|
||||
bool Metrics,
|
||||
bool Logging);
|
||||
|
||||
internal sealed record HealthDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
double UptimeSeconds,
|
||||
StorageHealth Storage,
|
||||
TelemetryHealth Telemetry);
|
||||
|
||||
internal sealed record ReadyDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
double UptimeSeconds,
|
||||
StorageHealth Storage);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal static class JobMetrics
|
||||
{
|
||||
internal const string MeterName = "StellaOps.Concelier.WebService.Jobs";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
internal static readonly Counter<long> TriggerCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.triggered",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests accepted by the web service.");
|
||||
|
||||
internal static readonly Counter<long> TriggerConflictCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.trigger.conflict",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests that resulted in conflicts or rejections.");
|
||||
|
||||
internal static readonly Counter<long> TriggerFailureCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.trigger.failed",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests that failed at runtime.");
|
||||
}
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal static class JobMetrics
|
||||
{
|
||||
internal const string MeterName = "StellaOps.Concelier.WebService.Jobs";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
internal static readonly Counter<long> TriggerCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.triggered",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests accepted by the web service.");
|
||||
|
||||
internal static readonly Counter<long> TriggerConflictCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.trigger.conflict",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests that resulted in conflicts or rejections.");
|
||||
|
||||
internal static readonly Counter<long> TriggerFailureCounter = Meter.CreateCounter<long>(
|
||||
"web.jobs.trigger.failed",
|
||||
unit: "count",
|
||||
description: "Number of job trigger requests that failed at runtime.");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal static class ProblemTypes
|
||||
{
|
||||
public const string NotFound = "https://stellaops.org/problems/not-found";
|
||||
public const string Validation = "https://stellaops.org/problems/validation";
|
||||
public const string Conflict = "https://stellaops.org/problems/conflict";
|
||||
public const string Locked = "https://stellaops.org/problems/locked";
|
||||
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";
|
||||
public const string JobFailure = "https://stellaops.org/problems/job-failure";
|
||||
public const string ServiceUnavailable = "https://stellaops.org/problems/service-unavailable";
|
||||
}
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal static class ProblemTypes
|
||||
{
|
||||
public const string NotFound = "https://stellaops.org/problems/not-found";
|
||||
public const string Validation = "https://stellaops.org/problems/validation";
|
||||
public const string Conflict = "https://stellaops.org/problems/conflict";
|
||||
public const string Locked = "https://stellaops.org/problems/locked";
|
||||
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";
|
||||
public const string JobFailure = "https://stellaops.org/problems/job-failure";
|
||||
public const string ServiceUnavailable = "https://stellaops.org/problems/service-unavailable";
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal sealed class ServiceStatus
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private DateTimeOffset? _bootstrapCompletedAt;
|
||||
private TimeSpan? _bootstrapDuration;
|
||||
private DateTimeOffset? _lastReadyCheckAt;
|
||||
private TimeSpan? _lastStorageLatency;
|
||||
private string? _lastStorageError;
|
||||
private bool _lastReadySucceeded;
|
||||
|
||||
public ServiceStatus(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_startedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public ServiceHealthSnapshot CreateSnapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return new ServiceHealthSnapshot(
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
StartedAt: _startedAt,
|
||||
BootstrapCompletedAt: _bootstrapCompletedAt,
|
||||
BootstrapDuration: _bootstrapDuration,
|
||||
LastReadyCheckAt: _lastReadyCheckAt,
|
||||
LastStorageLatency: _lastStorageLatency,
|
||||
LastStorageError: _lastStorageError,
|
||||
LastReadySucceeded: _lastReadySucceeded);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkBootstrapCompleted(TimeSpan duration)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
_bootstrapCompletedAt = completedAt;
|
||||
_bootstrapDuration = duration;
|
||||
_lastReadySucceeded = true;
|
||||
_lastStorageLatency = duration;
|
||||
_lastStorageError = null;
|
||||
_lastReadyCheckAt = completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordStorageCheck(bool success, TimeSpan latency, string? error)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_lastReadySucceeded = success;
|
||||
_lastStorageLatency = latency;
|
||||
_lastStorageError = success ? null : error;
|
||||
_lastReadyCheckAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ServiceHealthSnapshot(
|
||||
DateTimeOffset CapturedAt,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? BootstrapCompletedAt,
|
||||
TimeSpan? BootstrapDuration,
|
||||
DateTimeOffset? LastReadyCheckAt,
|
||||
TimeSpan? LastStorageLatency,
|
||||
string? LastStorageError,
|
||||
bool LastReadySucceeded);
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
internal sealed class ServiceStatus
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private DateTimeOffset? _bootstrapCompletedAt;
|
||||
private TimeSpan? _bootstrapDuration;
|
||||
private DateTimeOffset? _lastReadyCheckAt;
|
||||
private TimeSpan? _lastStorageLatency;
|
||||
private string? _lastStorageError;
|
||||
private bool _lastReadySucceeded;
|
||||
|
||||
public ServiceStatus(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_startedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public ServiceHealthSnapshot CreateSnapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return new ServiceHealthSnapshot(
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
StartedAt: _startedAt,
|
||||
BootstrapCompletedAt: _bootstrapCompletedAt,
|
||||
BootstrapDuration: _bootstrapDuration,
|
||||
LastReadyCheckAt: _lastReadyCheckAt,
|
||||
LastStorageLatency: _lastStorageLatency,
|
||||
LastStorageError: _lastStorageError,
|
||||
LastReadySucceeded: _lastReadySucceeded);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkBootstrapCompleted(TimeSpan duration)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
_bootstrapCompletedAt = completedAt;
|
||||
_bootstrapDuration = duration;
|
||||
_lastReadySucceeded = true;
|
||||
_lastStorageLatency = duration;
|
||||
_lastStorageError = null;
|
||||
_lastReadyCheckAt = completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordStorageCheck(bool success, TimeSpan latency, string? error)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_lastReadySucceeded = success;
|
||||
_lastStorageLatency = latency;
|
||||
_lastStorageError = success ? null : error;
|
||||
_lastReadyCheckAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ServiceHealthSnapshot(
|
||||
DateTimeOffset CapturedAt,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? BootstrapCompletedAt,
|
||||
TimeSpan? BootstrapDuration,
|
||||
DateTimeOffset? LastReadyCheckAt,
|
||||
TimeSpan? LastStorageLatency,
|
||||
string? LastStorageError,
|
||||
bool LastReadySucceeded);
|
||||
|
||||
@@ -5,16 +5,16 @@ using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
internal static class AdvisoryRawRequestMapper
|
||||
{
|
||||
internal static AdvisoryRawDocument Map(AdvisoryIngestRequest request, string tenant, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var sourceRequest = request.Source ?? throw new ArgumentException("source section is required.", nameof(request));
|
||||
|
||||
internal static class AdvisoryRawRequestMapper
|
||||
{
|
||||
internal static AdvisoryRawDocument Map(AdvisoryIngestRequest request, string tenant, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
var sourceRequest = request.Source ?? throw new ArgumentException("source section is required.", nameof(request));
|
||||
var upstreamRequest = request.Upstream ?? throw new ArgumentException("upstream section is required.", nameof(request));
|
||||
var contentRequest = request.Content ?? throw new ArgumentException("content section is required.", nameof(request));
|
||||
var identifiersRequest = request.Identifiers ?? throw new ArgumentException("identifiers section is required.", nameof(request));
|
||||
@@ -22,18 +22,18 @@ internal static class AdvisoryRawRequestMapper
|
||||
var source = new RawSourceMetadata(
|
||||
sourceRequest.Vendor,
|
||||
sourceRequest.Connector,
|
||||
sourceRequest.Version,
|
||||
string.IsNullOrWhiteSpace(sourceRequest.Stream) ? null : sourceRequest.Stream);
|
||||
|
||||
var signatureRequest = upstreamRequest.Signature ?? new AdvisorySignatureRequest(false, null, null, null, null, null);
|
||||
var signature = new RawSignatureMetadata(
|
||||
signatureRequest.Present,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Format) ? null : signatureRequest.Format,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.KeyId) ? null : signatureRequest.KeyId,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Signature) ? null : signatureRequest.Signature,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Certificate) ? null : signatureRequest.Certificate,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Digest) ? null : signatureRequest.Digest);
|
||||
|
||||
sourceRequest.Version,
|
||||
string.IsNullOrWhiteSpace(sourceRequest.Stream) ? null : sourceRequest.Stream);
|
||||
|
||||
var signatureRequest = upstreamRequest.Signature ?? new AdvisorySignatureRequest(false, null, null, null, null, null);
|
||||
var signature = new RawSignatureMetadata(
|
||||
signatureRequest.Present,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Format) ? null : signatureRequest.Format,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.KeyId) ? null : signatureRequest.KeyId,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Signature) ? null : signatureRequest.Signature,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Certificate) ? null : signatureRequest.Certificate,
|
||||
string.IsNullOrWhiteSpace(signatureRequest.Digest) ? null : signatureRequest.Digest);
|
||||
|
||||
var retrievedAt = upstreamRequest.RetrievedAt ?? timeProvider.GetUtcNow();
|
||||
var upstream = new RawUpstreamMetadata(
|
||||
upstreamRequest.UpstreamId,
|
||||
@@ -49,13 +49,13 @@ internal static class AdvisoryRawRequestMapper
|
||||
string.IsNullOrWhiteSpace(contentRequest.SpecVersion) ? null : contentRequest.SpecVersion,
|
||||
rawContent,
|
||||
string.IsNullOrWhiteSpace(contentRequest.Encoding) ? null : contentRequest.Encoding);
|
||||
|
||||
var aliases = NormalizeStrings(identifiersRequest.Aliases);
|
||||
if (aliases.IsDefault)
|
||||
{
|
||||
aliases = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
|
||||
var aliases = NormalizeStrings(identifiersRequest.Aliases);
|
||||
if (aliases.IsDefault)
|
||||
{
|
||||
aliases = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var identifiers = new RawIdentifiers(
|
||||
aliases,
|
||||
identifiersRequest.Primary);
|
||||
@@ -89,70 +89,70 @@ internal static class AdvisoryRawRequestMapper
|
||||
AdvisoryKey: advisoryKey,
|
||||
Links: links);
|
||||
}
|
||||
|
||||
internal static ImmutableArray<string> NormalizeStrings(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(value.Trim());
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
|
||||
}
|
||||
|
||||
internal static ImmutableDictionary<string, string> NormalizeDictionary(IDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[kv.Key.Trim()] = kv.Value?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
|
||||
internal static ImmutableArray<string> NormalizeStrings(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(value.Trim());
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
|
||||
}
|
||||
|
||||
internal static ImmutableDictionary<string, string> NormalizeDictionary(IDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[kv.Key.Trim()] = kv.Value?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<RawReference> NormalizeReferences(IEnumerable<AdvisoryLinksetReferenceRequest>? references)
|
||||
{
|
||||
if (references is null)
|
||||
{
|
||||
return ImmutableArray<RawReference>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RawReference>();
|
||||
foreach (var reference in references)
|
||||
{
|
||||
if (reference is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference.Type) || string.IsNullOrWhiteSpace(reference.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new RawReference(reference.Type.Trim(), reference.Url.Trim(), string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RawReference>();
|
||||
foreach (var reference in references)
|
||||
{
|
||||
if (reference is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference.Type) || string.IsNullOrWhiteSpace(reference.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new RawReference(reference.Type.Trim(), reference.Url.Trim(), string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<RawReference>.Empty : builder.ToImmutable();
|
||||
@@ -185,7 +185,7 @@ internal static class AdvisoryRawRequestMapper
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<RawRelationship>.Empty : builder.ToImmutable();
|
||||
}
|
||||
|
||||
|
||||
private static JsonElement NormalizeRawContent(JsonElement element)
|
||||
{
|
||||
var json = element.ValueKind == JsonValueKind.Undefined ? "{}" : element.GetRawText();
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
public static IConfigurationBuilder AddConcelierYaml(this IConfigurationBuilder builder, string path)
|
||||
{
|
||||
if (builder is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(builder));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
using var reader = File.OpenText(path);
|
||||
var yamlObject = deserializer.Deserialize(reader);
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(yamlObject);
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
return builder.AddJsonStream(stream);
|
||||
}
|
||||
}
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
public static IConfigurationBuilder AddConcelierYaml(this IConfigurationBuilder builder, string path)
|
||||
{
|
||||
if (builder is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(builder));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
using var reader = File.OpenText(path);
|
||||
var yamlObject = deserializer.Deserialize(reader);
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(yamlObject);
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
return builder.AddJsonStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +1,208 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
internal static class MirrorEndpointExtensions
|
||||
{
|
||||
private const string IndexScope = "index";
|
||||
private const string DownloadScope = "download";
|
||||
|
||||
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
|
||||
{
|
||||
app.MapGet("/concelier/exports/index.json", async (
|
||||
MirrorFileLocator locator,
|
||||
MirrorRateLimiter limiter,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveIndex(out var path, out _))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
|
||||
});
|
||||
|
||||
app.MapGet("/concelier/exports/{**relativePath}", async (
|
||||
string? relativePath,
|
||||
MirrorFileLocator locator,
|
||||
MirrorRateLimiter limiter,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
|
||||
}
|
||||
|
||||
var domain = FindDomain(mirrorOptions, domainId);
|
||||
|
||||
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
|
||||
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
var contentType = ResolveContentType(path);
|
||||
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
|
||||
{
|
||||
if (domainId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidate in mirrorOptions.Domains)
|
||||
{
|
||||
if (candidate is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
|
||||
{
|
||||
result = HttpResults.Empty;
|
||||
if (!requireAuthentication)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!enforceAuthority || !authorityConfigured)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
|
||||
result = HttpResults.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Task<IResult> WriteFileAsync(HttpContext context, string path, string contentType)
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return Task.FromResult(ConcelierProblemResultFactory.MirrorNotFound(context, path));
|
||||
}
|
||||
|
||||
var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read | FileShare.Delete);
|
||||
|
||||
context.Response.Headers.CacheControl = BuildCacheControlHeader(path);
|
||||
context.Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
context.Response.ContentLength = fileInfo.Length;
|
||||
return Task.FromResult(HttpResults.Stream(stream, contentType));
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "application/jose+json";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
|
||||
{
|
||||
if (retryAfter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
|
||||
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string BuildCacheControlHeader(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName is null)
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=300, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=300";
|
||||
}
|
||||
}
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
internal static class MirrorEndpointExtensions
|
||||
{
|
||||
private const string IndexScope = "index";
|
||||
private const string DownloadScope = "download";
|
||||
|
||||
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
|
||||
{
|
||||
app.MapGet("/concelier/exports/index.json", async (
|
||||
MirrorFileLocator locator,
|
||||
MirrorRateLimiter limiter,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveIndex(out var path, out _))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
|
||||
});
|
||||
|
||||
app.MapGet("/concelier/exports/{**relativePath}", async (
|
||||
string? relativePath,
|
||||
MirrorFileLocator locator,
|
||||
MirrorRateLimiter limiter,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
|
||||
{
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
|
||||
}
|
||||
|
||||
var domain = FindDomain(mirrorOptions, domainId);
|
||||
|
||||
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
{
|
||||
return unauthorizedResult;
|
||||
}
|
||||
|
||||
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
|
||||
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
var contentType = ResolveContentType(path);
|
||||
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
|
||||
{
|
||||
if (domainId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidate in mirrorOptions.Domains)
|
||||
{
|
||||
if (candidate is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
|
||||
{
|
||||
result = HttpResults.Empty;
|
||||
if (!requireAuthentication)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!enforceAuthority || !authorityConfigured)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
|
||||
result = HttpResults.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Task<IResult> WriteFileAsync(HttpContext context, string path, string contentType)
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return Task.FromResult(ConcelierProblemResultFactory.MirrorNotFound(context, path));
|
||||
}
|
||||
|
||||
var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read | FileShare.Delete);
|
||||
|
||||
context.Response.Headers.CacheControl = BuildCacheControlHeader(path);
|
||||
context.Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
context.Response.ContentLength = fileInfo.Length;
|
||||
return Task.FromResult(HttpResults.Stream(stream, contentType));
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "application/jose+json";
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
|
||||
{
|
||||
if (retryAfter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
|
||||
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string BuildCacheControlHeader(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName is null)
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=300, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=300";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
|
||||
/// </summary>
|
||||
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
|
||||
{
|
||||
internal const string LoggerName = "Concelier.Authorization.Audit";
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var options = httpContext.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
||||
var authority = options.Authority;
|
||||
|
||||
if (authority is null || !authority.Enabled)
|
||||
{
|
||||
return await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var logger = httpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(LoggerName);
|
||||
|
||||
var remoteAddress = httpContext.Connection.RemoteIpAddress;
|
||||
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
|
||||
var user = httpContext.User;
|
||||
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
|
||||
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
|
||||
|
||||
var result = await next(context).ConfigureAwait(false);
|
||||
|
||||
var scopes = ExtractScopes(user);
|
||||
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
|
||||
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
|
||||
|
||||
logger.LogInformation(
|
||||
"Concelier authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
|
||||
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
|
||||
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
|
||||
bypassUsed,
|
||||
remoteAddress?.ToString() ?? IPAddress.None.ToString());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ExtractScopes(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var values = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values.Add(claim.Value);
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
values.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
|
||||
/// </summary>
|
||||
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
|
||||
{
|
||||
internal const string LoggerName = "Concelier.Authorization.Audit";
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var options = httpContext.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value;
|
||||
var authority = options.Authority;
|
||||
|
||||
if (authority is null || !authority.Enabled)
|
||||
{
|
||||
return await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var logger = httpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(LoggerName);
|
||||
|
||||
var remoteAddress = httpContext.Connection.RemoteIpAddress;
|
||||
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
|
||||
var user = httpContext.User;
|
||||
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
|
||||
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
|
||||
|
||||
var result = await next(context).ConfigureAwait(false);
|
||||
|
||||
var scopes = ExtractScopes(user);
|
||||
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
|
||||
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
|
||||
|
||||
logger.LogInformation(
|
||||
"Concelier authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
|
||||
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
|
||||
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
|
||||
bypassUsed,
|
||||
remoteAddress?.ToString() ?? IPAddress.None.ToString());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ExtractScopes(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var values = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values.Add(claim.Value);
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
values.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed record JobDefinitionResponse(
|
||||
string Kind,
|
||||
bool Enabled,
|
||||
string? CronExpression,
|
||||
TimeSpan Timeout,
|
||||
TimeSpan LeaseDuration,
|
||||
JobRunResponse? LastRun)
|
||||
{
|
||||
public static JobDefinitionResponse FromDefinition(JobDefinition definition, JobRunSnapshot? lastRun)
|
||||
{
|
||||
return new JobDefinitionResponse(
|
||||
definition.Kind,
|
||||
definition.Enabled,
|
||||
definition.CronExpression,
|
||||
definition.Timeout,
|
||||
definition.LeaseDuration,
|
||||
lastRun is null ? null : JobRunResponse.FromSnapshot(lastRun));
|
||||
}
|
||||
}
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed record JobDefinitionResponse(
|
||||
string Kind,
|
||||
bool Enabled,
|
||||
string? CronExpression,
|
||||
TimeSpan Timeout,
|
||||
TimeSpan LeaseDuration,
|
||||
JobRunResponse? LastRun)
|
||||
{
|
||||
public static JobDefinitionResponse FromDefinition(JobDefinition definition, JobRunSnapshot? lastRun)
|
||||
{
|
||||
return new JobDefinitionResponse(
|
||||
definition.Kind,
|
||||
definition.Enabled,
|
||||
definition.CronExpression,
|
||||
definition.Timeout,
|
||||
definition.LeaseDuration,
|
||||
lastRun is null ? null : JobRunResponse.FromSnapshot(lastRun));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed record JobRunResponse(
|
||||
Guid RunId,
|
||||
string Kind,
|
||||
JobRunStatus Status,
|
||||
string Trigger,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? Error,
|
||||
TimeSpan? Duration,
|
||||
IReadOnlyDictionary<string, object?> Parameters)
|
||||
{
|
||||
public static JobRunResponse FromSnapshot(JobRunSnapshot snapshot)
|
||||
=> new(
|
||||
snapshot.RunId,
|
||||
snapshot.Kind,
|
||||
snapshot.Status,
|
||||
snapshot.Trigger,
|
||||
snapshot.CreatedAt,
|
||||
snapshot.StartedAt,
|
||||
snapshot.CompletedAt,
|
||||
snapshot.Error,
|
||||
snapshot.Duration,
|
||||
snapshot.Parameters);
|
||||
}
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed record JobRunResponse(
|
||||
Guid RunId,
|
||||
string Kind,
|
||||
JobRunStatus Status,
|
||||
string Trigger,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? Error,
|
||||
TimeSpan? Duration,
|
||||
IReadOnlyDictionary<string, object?> Parameters)
|
||||
{
|
||||
public static JobRunResponse FromSnapshot(JobRunSnapshot snapshot)
|
||||
=> new(
|
||||
snapshot.RunId,
|
||||
snapshot.Kind,
|
||||
snapshot.Status,
|
||||
snapshot.Trigger,
|
||||
snapshot.CreatedAt,
|
||||
snapshot.StartedAt,
|
||||
snapshot.CompletedAt,
|
||||
snapshot.Error,
|
||||
snapshot.Duration,
|
||||
snapshot.Parameters);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "api";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
namespace StellaOps.Concelier.WebService.Jobs;
|
||||
|
||||
public sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "api";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Post-configuration helpers for <see cref="ConcelierOptions"/>.
|
||||
/// </summary>
|
||||
public static class ConcelierOptionsPostConfigure
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies derived settings that require filesystem access, such as loading client secrets from disk.
|
||||
/// </summary>
|
||||
/// <param name="options">The options to mutate.</param>
|
||||
/// <param name="contentRootPath">Application content root used to resolve relative paths.</param>
|
||||
public static void Apply(ConcelierOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Post-configuration helpers for <see cref="ConcelierOptions"/>.
|
||||
/// </summary>
|
||||
public static class ConcelierOptionsPostConfigure
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies derived settings that require filesystem access, such as loading client secrets from disk.
|
||||
/// </summary>
|
||||
/// <param name="options">The options to mutate.</param>
|
||||
/// <param name="contentRootPath">Application content root used to resolve relative paths.</param>
|
||||
public static void Apply(ConcelierOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
options.Features ??= new ConcelierOptions.FeaturesOptions();
|
||||
options.Evidence ??= new ConcelierOptions.EvidenceBundleOptions();
|
||||
|
||||
var authority = options.Authority;
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
|
||||
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
|
||||
{
|
||||
var resolvedPath = authority.ClientSecretFile!;
|
||||
if (!Path.IsPathRooted(resolvedPath))
|
||||
{
|
||||
resolvedPath = Path.Combine(contentRootPath, resolvedPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found.");
|
||||
}
|
||||
|
||||
var secret = File.ReadAllText(resolvedPath).Trim();
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty.");
|
||||
}
|
||||
|
||||
authority.ClientSecret = secret;
|
||||
}
|
||||
|
||||
|
||||
var authority = options.Authority;
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
|
||||
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
|
||||
{
|
||||
var resolvedPath = authority.ClientSecretFile!;
|
||||
if (!Path.IsPathRooted(resolvedPath))
|
||||
{
|
||||
resolvedPath = Path.Combine(contentRootPath, resolvedPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found.");
|
||||
}
|
||||
|
||||
var secret = File.ReadAllText(resolvedPath).Trim();
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty.");
|
||||
}
|
||||
|
||||
authority.ClientSecret = secret;
|
||||
}
|
||||
|
||||
options.Mirror ??= new ConcelierOptions.MirrorOptions();
|
||||
var mirror = options.Mirror;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
|
||||
{
|
||||
mirror.ExportRoot = Path.Combine("exports", "json");
|
||||
}
|
||||
|
||||
var resolvedRoot = mirror.ExportRoot;
|
||||
if (!Path.IsPathRooted(resolvedRoot))
|
||||
{
|
||||
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
|
||||
}
|
||||
|
||||
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
|
||||
{
|
||||
mirror.LatestDirectoryName = "latest";
|
||||
}
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
|
||||
{
|
||||
mirror.ExportRoot = Path.Combine("exports", "json");
|
||||
}
|
||||
|
||||
var resolvedRoot = mirror.ExportRoot;
|
||||
if (!Path.IsPathRooted(resolvedRoot))
|
||||
{
|
||||
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
|
||||
}
|
||||
|
||||
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
|
||||
{
|
||||
mirror.LatestDirectoryName = "latest";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
|
||||
{
|
||||
mirror.MirrorDirectoryName = "mirror";
|
||||
|
||||
@@ -65,7 +65,7 @@ using StellaOps.Aoc.AspNetCore.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
using StellaOps.Provenance;
|
||||
|
||||
namespace StellaOps.Concelier.WebService
|
||||
{
|
||||
|
||||
@@ -1,184 +1,184 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed class MirrorFileLocator
|
||||
{
|
||||
private readonly IOptionsMonitor<ConcelierOptions> _options;
|
||||
private readonly ILogger<MirrorFileLocator> _logger;
|
||||
|
||||
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
|
||||
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
|
||||
|
||||
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
|
||||
{
|
||||
fullPath = null;
|
||||
exportId = null;
|
||||
domainId = null;
|
||||
|
||||
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirror.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sanitized = SanitizeRelativePath(relativePath);
|
||||
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
|
||||
}
|
||||
|
||||
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = Combine(exportDirectory, sanitized);
|
||||
if (!CandidateWithinExport(exportDirectory, candidate))
|
||||
{
|
||||
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract domain id from path mirror/<domain>/...
|
||||
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
domainId = segments[1];
|
||||
}
|
||||
|
||||
fullPath = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
|
||||
{
|
||||
exportDirectory = null;
|
||||
exportId = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
|
||||
{
|
||||
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = mirror.ExportRootAbsolute;
|
||||
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
|
||||
? mirror.LatestDirectoryName
|
||||
: mirror.ActiveExportId!;
|
||||
|
||||
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
|
||||
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directories = Directory.Exists(root)
|
||||
? Directory.GetDirectories(root)
|
||||
: Array.Empty<string>();
|
||||
|
||||
Array.Sort(directories, StringComparer.Ordinal);
|
||||
Array.Reverse(directories);
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
|
||||
{
|
||||
exportDirectory = null;
|
||||
exportId = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = Path.Combine(root, segment);
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
|
||||
if (!Directory.Exists(mirrorPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
exportDirectory = candidate;
|
||||
exportId = segment;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string SanitizeRelativePath(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string Combine(string root, string relativePath)
|
||||
{
|
||||
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
var combinedRelative = Path.Combine(segments);
|
||||
return Path.GetFullPath(Path.Combine(root, combinedRelative));
|
||||
}
|
||||
|
||||
private static bool CandidateWithinExport(string exportDirectory, string candidate)
|
||||
{
|
||||
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var candidatePath = Path.GetFullPath(candidate);
|
||||
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed class MirrorFileLocator
|
||||
{
|
||||
private readonly IOptionsMonitor<ConcelierOptions> _options;
|
||||
private readonly ILogger<MirrorFileLocator> _logger;
|
||||
|
||||
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
|
||||
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
|
||||
|
||||
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
|
||||
{
|
||||
fullPath = null;
|
||||
exportId = null;
|
||||
domainId = null;
|
||||
|
||||
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirror.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sanitized = SanitizeRelativePath(relativePath);
|
||||
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
|
||||
}
|
||||
|
||||
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = Combine(exportDirectory, sanitized);
|
||||
if (!CandidateWithinExport(exportDirectory, candidate))
|
||||
{
|
||||
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract domain id from path mirror/<domain>/...
|
||||
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
domainId = segments[1];
|
||||
}
|
||||
|
||||
fullPath = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
|
||||
{
|
||||
exportDirectory = null;
|
||||
exportId = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
|
||||
{
|
||||
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = mirror.ExportRootAbsolute;
|
||||
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
|
||||
? mirror.LatestDirectoryName
|
||||
: mirror.ActiveExportId!;
|
||||
|
||||
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
|
||||
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directories = Directory.Exists(root)
|
||||
? Directory.GetDirectories(root)
|
||||
: Array.Empty<string>();
|
||||
|
||||
Array.Sort(directories, StringComparer.Ordinal);
|
||||
Array.Reverse(directories);
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
|
||||
{
|
||||
exportDirectory = null;
|
||||
exportId = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = Path.Combine(root, segment);
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
|
||||
if (!Directory.Exists(mirrorPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
exportDirectory = candidate;
|
||||
exportId = segment;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string SanitizeRelativePath(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string Combine(string root, string relativePath)
|
||||
{
|
||||
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
var combinedRelative = Path.Combine(segments);
|
||||
return Path.GetFullPath(Path.Combine(root, combinedRelative));
|
||||
}
|
||||
|
||||
private static bool CandidateWithinExport(string exportDirectory, string candidate)
|
||||
{
|
||||
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var candidatePath = Path.GetFullPath(candidate);
|
||||
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed class MirrorRateLimiter
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
|
||||
|
||||
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
|
||||
{
|
||||
retryAfter = null;
|
||||
|
||||
if (limit <= 0 || limit == int.MaxValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = CreateKey(domainId, scope);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var counter = _cache.Get<Counter>(key);
|
||||
if (counter is null || now - counter.WindowStart >= Window)
|
||||
{
|
||||
counter = new Counter(now, 0);
|
||||
}
|
||||
|
||||
if (counter.Count >= limit)
|
||||
{
|
||||
var windowEnd = counter.WindowStart + Window;
|
||||
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
counter = counter with { Count = counter.Count + 1 };
|
||||
var absoluteExpiration = counter.WindowStart + Window;
|
||||
_cache.Set(key, counter, absoluteExpiration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string CreateKey(string domainId, string scope)
|
||||
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
|
||||
{
|
||||
state.domainId.AsSpan().CopyTo(span);
|
||||
span[state.domainId.Length] = '|';
|
||||
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private sealed record Counter(DateTimeOffset WindowStart, int Count);
|
||||
}
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed class MirrorRateLimiter
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
|
||||
|
||||
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
|
||||
{
|
||||
retryAfter = null;
|
||||
|
||||
if (limit <= 0 || limit == int.MaxValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = CreateKey(domainId, scope);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var counter = _cache.Get<Counter>(key);
|
||||
if (counter is null || now - counter.WindowStart >= Window)
|
||||
{
|
||||
counter = new Counter(now, 0);
|
||||
}
|
||||
|
||||
if (counter.Count >= limit)
|
||||
{
|
||||
var windowEnd = counter.WindowStart + Window;
|
||||
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
counter = counter with { Count = counter.Count + 1 };
|
||||
var absoluteExpiration = counter.WindowStart + Window;
|
||||
_cache.Set(key, counter, absoluteExpiration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string CreateKey(string domainId, string scope)
|
||||
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
|
||||
{
|
||||
state.domainId.AsSpan().CopyTo(span);
|
||||
span[state.domainId.Length] = '|';
|
||||
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private sealed record Counter(DateTimeOffset WindowStart, int Count);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user