feat: Implement MongoDB orchestrator storage with registry, commands, and heartbeats
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added NullAdvisoryObservationEventTransport for handling advisory observation events. - Created IOrchestratorRegistryStore interface for orchestrator registry operations. - Implemented MongoOrchestratorRegistryStore for MongoDB interactions with orchestrator data. - Defined OrchestratorCommandDocument and OrchestratorCommandRecord for command handling. - Added OrchestratorHeartbeatDocument and OrchestratorHeartbeatRecord for heartbeat tracking. - Created OrchestratorRegistryDocument and OrchestratorRegistryRecord for registry management. - Developed tests for orchestrator collections migration and MongoOrchestratorRegistryStore functionality. - Introduced AirgapImportRequest and AirgapImportValidator for air-gapped VEX bundle imports. - Added incident mode rules sample JSON for notifier configuration.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
@@ -89,7 +90,8 @@ public sealed record AdvisoryRawListResponse(
|
||||
|
||||
public sealed record AdvisoryEvidenceResponse(
|
||||
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
|
||||
[property: JsonPropertyName("records")] IReadOnlyList<AdvisoryRawRecordResponse> Records);
|
||||
[property: JsonPropertyName("records")] IReadOnlyList<AdvisoryRawRecordResponse> Records,
|
||||
[property: JsonPropertyName("attestation")] AttestationClaims? Attestation);
|
||||
|
||||
public sealed record AdvisoryRawProvenanceResponse(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Concelier.Storage.Mongo.Orchestrator;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record OrchestratorRegistryRequest(
|
||||
[property: Required] string ConnectorId,
|
||||
[property: Required] string Source,
|
||||
[property: Required] string[] Capabilities,
|
||||
[property: Required] string AuthRef,
|
||||
[property: Required] OrchestratorScheduleDto Schedule,
|
||||
[property: Required] OrchestratorRatePolicyDto RatePolicy,
|
||||
[property: Required] string[] ArtifactKinds,
|
||||
[property: Required] string LockKey,
|
||||
[property: Required] OrchestratorEgressGuardDto EgressGuard);
|
||||
|
||||
public sealed record OrchestratorScheduleDto(
|
||||
string Cron,
|
||||
string TimeZone,
|
||||
int MaxParallelRuns,
|
||||
int MaxLagMinutes);
|
||||
|
||||
public sealed record OrchestratorRatePolicyDto(
|
||||
int Rpm,
|
||||
int Burst,
|
||||
int CooldownSeconds);
|
||||
|
||||
public sealed record OrchestratorEgressGuardDto(
|
||||
string[] Allowlist,
|
||||
bool AirgapMode);
|
||||
|
||||
public sealed record OrchestratorHeartbeatRequest(
|
||||
[property: Required] string ConnectorId,
|
||||
[property: Required] Guid RunId,
|
||||
[property: Required] long Sequence,
|
||||
[property: Required] OrchestratorHeartbeatStatus Status,
|
||||
int? Progress,
|
||||
int? QueueDepth,
|
||||
string? LastArtifactHash,
|
||||
string? LastArtifactKind,
|
||||
string? ErrorCode,
|
||||
int? RetryAfterSeconds,
|
||||
DateTimeOffset? TimestampUtc);
|
||||
|
||||
public sealed record OrchestratorCommandRequest(
|
||||
[property: Required] string ConnectorId,
|
||||
[property: Required] Guid RunId,
|
||||
[property: Required] long Sequence,
|
||||
[property: Required] OrchestratorCommandKind Command,
|
||||
OrchestratorThrottleOverrideDto? Throttle,
|
||||
OrchestratorBackfillRangeDto? Backfill,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
public sealed record OrchestratorThrottleOverrideDto(int? Rpm, int? Burst, int? CooldownSeconds, DateTimeOffset? ExpiresAt);
|
||||
|
||||
public sealed record OrchestratorBackfillRangeDto(string? FromCursor, string? ToCursor);
|
||||
@@ -21,6 +21,8 @@ public sealed class ConcelierOptions
|
||||
|
||||
public AdvisoryChunkOptions AdvisoryChunks { get; set; } = new();
|
||||
|
||||
public EvidenceBundleOptions Evidence { get; set; } = new();
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
@@ -172,4 +174,20 @@ public sealed class ConcelierOptions
|
||||
|
||||
public int CacheDurationSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
public sealed class EvidenceBundleOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Root { get; set; } = System.IO.Path.Combine("out", "evidence", "bundles");
|
||||
|
||||
public string? DefaultManifestFileName { get; set; } = "manifest.json";
|
||||
|
||||
public string? DefaultTransparencyFileName { get; set; } = "transparency.json";
|
||||
|
||||
public string PipelineVersion { get; set; } = "git:unknown";
|
||||
|
||||
[JsonIgnore]
|
||||
public string RootAbsolute { get; internal set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class ConcelierOptionsPostConfigure
|
||||
|
||||
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
options.Features ??= new ConcelierOptions.FeaturesOptions();
|
||||
options.Evidence ??= new ConcelierOptions.EvidenceBundleOptions();
|
||||
|
||||
var authority = options.Authority;
|
||||
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
|
||||
@@ -44,8 +45,8 @@ public static class ConcelierOptionsPostConfigure
|
||||
authority.ClientSecret = secret;
|
||||
}
|
||||
|
||||
options.Mirror ??= new ConcelierOptions.MirrorOptions();
|
||||
var mirror = options.Mirror;
|
||||
options.Mirror ??= new ConcelierOptions.MirrorOptions();
|
||||
var mirror = options.Mirror;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
|
||||
{
|
||||
@@ -65,9 +66,33 @@ public static class ConcelierOptionsPostConfigure
|
||||
mirror.LatestDirectoryName = "latest";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
|
||||
{
|
||||
mirror.MirrorDirectoryName = "mirror";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
|
||||
{
|
||||
mirror.MirrorDirectoryName = "mirror";
|
||||
}
|
||||
|
||||
var evidence = options.Evidence;
|
||||
if (string.IsNullOrWhiteSpace(evidence.Root))
|
||||
{
|
||||
evidence.Root = Path.Combine("out", "evidence", "bundles");
|
||||
}
|
||||
|
||||
var evidenceRoot = evidence.Root;
|
||||
if (!Path.IsPathRooted(evidenceRoot))
|
||||
{
|
||||
evidenceRoot = Path.Combine(contentRootPath, evidenceRoot);
|
||||
}
|
||||
|
||||
evidence.RootAbsolute = Path.GetFullPath(evidenceRoot);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence.DefaultManifestFileName))
|
||||
{
|
||||
evidence.DefaultManifestFileName = "manifest.json";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence.DefaultTransparencyFileName))
|
||||
{
|
||||
evidence.DefaultTransparencyFileName = "transparency.json";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ public static class ConcelierOptionsValidator
|
||||
options.Mirror ??= new ConcelierOptions.MirrorOptions();
|
||||
ValidateMirror(options.Mirror);
|
||||
|
||||
options.Evidence ??= new ConcelierOptions.EvidenceBundleOptions();
|
||||
ValidateEvidence(options.Evidence);
|
||||
|
||||
options.AdvisoryChunks ??= new ConcelierOptions.AdvisoryChunkOptions();
|
||||
ValidateAdvisoryChunks(options.AdvisoryChunks);
|
||||
}
|
||||
@@ -312,4 +315,22 @@ public static class ConcelierOptionsValidator
|
||||
throw new InvalidOperationException("Advisory chunk cacheDurationSeconds must be greater than or equal to zero.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEvidence(ConcelierOptions.EvidenceBundleOptions evidence)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(evidence.Root))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle root must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence.RootAbsolute))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle root could not be resolved.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence.PipelineVersion))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle pipelineVersion must be provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using StellaOps.Concelier.Storage.Mongo.Orchestrator;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -398,6 +399,162 @@ app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvi
|
||||
}
|
||||
}).WithName("GetConcelierOpenApiDocument");
|
||||
|
||||
var orchestratorGroup = app.MapGroup("/internal/orch");
|
||||
if (authorityConfigured)
|
||||
{
|
||||
orchestratorGroup.RequireAuthorization();
|
||||
}
|
||||
|
||||
orchestratorGroup.MapPost("/registry", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorRegistryRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId) || string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
return Problem(context, "connectorId and source are required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId and source.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var record = new OrchestratorRegistryRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.Source.Trim(),
|
||||
request.Capabilities,
|
||||
request.AuthRef,
|
||||
new OrchestratorSchedule(
|
||||
request.Schedule.Cron,
|
||||
string.IsNullOrWhiteSpace(request.Schedule.TimeZone) ? "UTC" : request.Schedule.TimeZone,
|
||||
request.Schedule.MaxParallelRuns,
|
||||
request.Schedule.MaxLagMinutes),
|
||||
new OrchestratorRatePolicy(request.RatePolicy.Rpm, request.RatePolicy.Burst, request.RatePolicy.CooldownSeconds),
|
||||
request.ArtifactKinds,
|
||||
request.LockKey,
|
||||
new OrchestratorEgressGuard(request.EgressGuard.Allowlist, request.EgressGuard.AirgapMode),
|
||||
now,
|
||||
now);
|
||||
|
||||
await store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Accepted();
|
||||
}).WithName("UpsertOrchestratorRegistry");
|
||||
|
||||
orchestratorGroup.MapPost("/heartbeat", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorHeartbeatRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
if (request.Sequence < 0)
|
||||
{
|
||||
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
|
||||
}
|
||||
|
||||
var timestamp = request.TimestampUtc ?? timeProvider.GetUtcNow();
|
||||
var heartbeat = new OrchestratorHeartbeatRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.RunId,
|
||||
request.Sequence,
|
||||
request.Status,
|
||||
request.Progress,
|
||||
request.QueueDepth,
|
||||
request.LastArtifactHash,
|
||||
request.LastArtifactKind,
|
||||
request.ErrorCode,
|
||||
request.RetryAfterSeconds,
|
||||
timestamp);
|
||||
|
||||
await store.AppendHeartbeatAsync(heartbeat, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted();
|
||||
}).WithName("RecordOrchestratorHeartbeat");
|
||||
|
||||
orchestratorGroup.MapPost("/commands", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorCommandRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
if (request.Sequence < 0)
|
||||
{
|
||||
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
|
||||
}
|
||||
|
||||
var command = new OrchestratorCommandRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.RunId,
|
||||
request.Sequence,
|
||||
request.Command,
|
||||
request.Throttle is null
|
||||
? null
|
||||
: new OrchestratorThrottleOverride(
|
||||
request.Throttle.Rpm,
|
||||
request.Throttle.Burst,
|
||||
request.Throttle.CooldownSeconds,
|
||||
request.Throttle.ExpiresAt),
|
||||
request.Backfill is null
|
||||
? null
|
||||
: new OrchestratorBackfillRange(request.Backfill.FromCursor, request.Backfill.ToCursor),
|
||||
DateTimeOffset.UtcNow,
|
||||
request.ExpiresAt);
|
||||
|
||||
await store.EnqueueCommandAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted();
|
||||
}).WithName("EnqueueOrchestratorCommand");
|
||||
|
||||
orchestratorGroup.MapGet("/commands", async (
|
||||
HttpContext context,
|
||||
[FromQuery] string connectorId,
|
||||
[FromQuery] Guid runId,
|
||||
[FromQuery] long? afterSequence,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
var commands = await store.GetPendingCommandsAsync(tenant, connectorId.Trim(), runId, afterSequence, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(commands);
|
||||
}).WithName("GetOrchestratorCommands");
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
|
||||
@@ -836,6 +993,7 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryRawService rawService,
|
||||
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
|
||||
[FromServices] ILogger<Program> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -879,10 +1037,16 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
||||
record.Document))
|
||||
.ToArray();
|
||||
|
||||
var evidenceOptions = resolvedConcelierOptions.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
||||
var attestation = await TryBuildAttestationAsync(
|
||||
context,
|
||||
evidenceOptions,
|
||||
attestationBuilder,
|
||||
logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey;
|
||||
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses);
|
||||
// TODO: Attach attestation metadata when Evidence Bundle tarball is available per tenant/advisory.
|
||||
// The builder is registered for future use once bundle paths are discoverable from evidence storage.
|
||||
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses, attestation);
|
||||
return JsonResult(response);
|
||||
});
|
||||
if (authorityConfigured)
|
||||
@@ -1622,6 +1786,120 @@ static KeyValuePair<string, object?>[] BuildJobMetricTags(string jobKind, string
|
||||
new KeyValuePair<string, object?>("job.outcome", outcome),
|
||||
};
|
||||
|
||||
static async Task<AttestationClaims?> TryBuildAttestationAsync(
|
||||
HttpContext context,
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
EvidenceBundleAttestationBuilder builder,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundlePath = context.Request.Query.TryGetValue("bundlePath", out var bundleValues)
|
||||
? bundleValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifestPath = context.Request.Query.TryGetValue("manifestPath", out var manifestValues)
|
||||
? manifestValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var transparencyPath = context.Request.Query.TryGetValue("transparencyPath", out var transparencyValues)
|
||||
? transparencyValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var pipelineVersion = context.Request.Query.TryGetValue("pipelineVersion", out var pipelineValues)
|
||||
? pipelineValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
pipelineVersion = string.IsNullOrWhiteSpace(pipelineVersion)
|
||||
? evidenceOptions.PipelineVersion
|
||||
: pipelineVersion.Trim();
|
||||
|
||||
var root = evidenceOptions.RootAbsolute;
|
||||
var resolvedBundlePath = ResolveEvidencePath(bundlePath, root);
|
||||
if (string.IsNullOrWhiteSpace(resolvedBundlePath) || !File.Exists(resolvedBundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedManifestPath = string.IsNullOrWhiteSpace(manifestPath)
|
||||
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultManifestFileName)
|
||||
: ResolveEvidencePath(manifestPath!, root);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedManifestPath) || !File.Exists(resolvedManifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedTransparencyPath = string.IsNullOrWhiteSpace(transparencyPath)
|
||||
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultTransparencyFileName)
|
||||
: ResolveEvidencePath(transparencyPath!, root);
|
||||
|
||||
try
|
||||
{
|
||||
return await builder.BuildAsync(
|
||||
new EvidenceBundleAttestationRequest(
|
||||
resolvedBundlePath!,
|
||||
resolvedManifestPath!,
|
||||
resolvedTransparencyPath,
|
||||
pipelineVersion ?? "git:unknown"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to build attestation for evidence bundle {BundlePath}", resolvedBundlePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static string? ResolveEvidencePath(string candidate, string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = candidate;
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(root, path);
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
var rootPath = Path.GetFullPath(root)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
static string? ResolveSibling(string? bundlePath, string? fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(bundlePath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
void ApplyNoCache(HttpResponse response)
|
||||
{
|
||||
if (response is null)
|
||||
|
||||
Reference in New Issue
Block a user