Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly.
- Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified.
- Created tests for ConfigValidationResult to check success and error scenarios.
- Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig.
- Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport.
- Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -74,6 +74,7 @@ internal static class CommandFactory
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
@@ -4207,12 +4208,22 @@ internal static class CommandFactory
{
Description = "Output path for verification report."
};
var verifyFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format: table (default), json."
};
var verifyExplainOption = new Option<bool>("--explain")
{
Description = "Include detailed explanations for each verification check."
};
verify.Add(envelopeOption);
verify.Add(policyOption);
verify.Add(rootOption);
verify.Add(checkpointOption);
verify.Add(verifyOutputOption);
verify.Add(verifyFormatOption);
verify.Add(verifyExplainOption);
verify.SetAction((parseResult, _) =>
{
@@ -4221,44 +4232,70 @@ internal static class CommandFactory
var root = parseResult.GetValue(rootOption);
var checkpoint = parseResult.GetValue(checkpointOption);
var output = parseResult.GetValue(verifyOutputOption);
var format = parseResult.GetValue(verifyFormatOption) ?? "table";
var explain = parseResult.GetValue(verifyExplainOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestVerifyAsync(services, envelope, policy, root, checkpoint, output, verbose, cancellationToken);
return CommandHandlers.HandleAttestVerifyAsync(services, envelope, policy, root, checkpoint, output, format, explain, verbose, cancellationToken);
});
// attest list
var list = new Command("list", "List attestations from the backend.");
var tenantOption = new Option<string?>("--tenant")
// attest list (CLI-ATTEST-74-001)
var list = new Command("list", "List attestations from local storage or backend.");
var listTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier to filter by."
Description = "Filter by tenant identifier."
};
var issuerOption = new Option<string?>("--issuer")
var listIssuerOption = new Option<string?>("--issuer")
{
Description = "Issuer identifier to filter by."
Description = "Filter by issuer identifier."
};
var formatOption = new Option<string?>("--format", new[] { "-f" })
var listSubjectOption = new Option<string?>("--subject", new[] { "-s" })
{
Description = "Output format (table, json)."
Description = "Filter by subject (e.g., image digest, package PURL)."
};
var limitOption = new Option<int?>("--limit", new[] { "-n" })
var listTypeOption = new Option<string?>("--type", new[] { "-t" })
{
Description = "Maximum number of results to return."
Description = "Filter by predicate type URI."
};
var listScopeOption = new Option<string?>("--scope")
{
Description = "Filter by scope (local, remote, all). Default: all."
};
var listFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format (table, json). Default: table."
};
var listLimitOption = new Option<int?>("--limit", new[] { "-n" })
{
Description = "Maximum number of results to return. Default: 50."
};
var listOffsetOption = new Option<int?>("--offset")
{
Description = "Number of results to skip (for pagination). Default: 0."
};
list.Add(tenantOption);
list.Add(issuerOption);
list.Add(formatOption);
list.Add(limitOption);
list.Add(listTenantOption);
list.Add(listIssuerOption);
list.Add(listSubjectOption);
list.Add(listTypeOption);
list.Add(listScopeOption);
list.Add(listFormatOption);
list.Add(listLimitOption);
list.Add(listOffsetOption);
list.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(tenantOption);
var issuer = parseResult.GetValue(issuerOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var limit = parseResult.GetValue(limitOption);
var tenant = parseResult.GetValue(listTenantOption);
var issuer = parseResult.GetValue(listIssuerOption);
var subject = parseResult.GetValue(listSubjectOption);
var type = parseResult.GetValue(listTypeOption);
var scope = parseResult.GetValue(listScopeOption) ?? "all";
var format = parseResult.GetValue(listFormatOption) ?? "table";
var limit = parseResult.GetValue(listLimitOption);
var offset = parseResult.GetValue(listOffsetOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestListAsync(services, tenant, issuer, format, limit, verbose, cancellationToken);
return CommandHandlers.HandleAttestListAsync(services, tenant, issuer, subject, type, scope, format, limit, offset, verbose, cancellationToken);
});
// attest show
@@ -4291,9 +4328,398 @@ internal static class CommandFactory
return CommandHandlers.HandleAttestShowAsync(services, id, output, includeProof, verbose, cancellationToken);
});
// attest sign (CLI-ATTEST-73-001)
var sign = new Command("sign", "Create and sign a DSSE attestation envelope.");
var predicateFileOption = new Option<string>("--predicate", new[] { "-p" })
{
Description = "Path to the predicate JSON file.",
Required = true
};
var predicateTypeOption = new Option<string>("--predicate-type")
{
Description = "Predicate type URI (e.g., https://slsa.dev/provenance/v1).",
Required = true
};
var subjectNameOption = new Option<string>("--subject")
{
Description = "Subject name or URI to attest.",
Required = true
};
var subjectDigestOption = new Option<string>("--digest")
{
Description = "Subject digest in format algorithm:hex (e.g., sha256:abc123...).",
Required = true
};
var signKeyOption = new Option<string?>("--key", new[] { "-k" })
{
Description = "Key identifier or path for signing."
};
var keylessOption = new Option<bool>("--keyless")
{
Description = "Use keyless (OIDC) signing via Sigstore Fulcio."
};
var transparencyLogOption = new Option<bool>("--rekor")
{
Description = "Submit attestation to Rekor transparency log (default: false)."
};
var noRekorOption = new Option<bool>("--no-rekor")
{
Description = "Explicitly skip Rekor submission."
};
var signOutputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output path for the signed DSSE envelope JSON."
};
var signFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format: dsse (default), sigstore-bundle."
};
sign.Add(predicateFileOption);
sign.Add(predicateTypeOption);
sign.Add(subjectNameOption);
sign.Add(subjectDigestOption);
sign.Add(signKeyOption);
sign.Add(keylessOption);
sign.Add(transparencyLogOption);
sign.Add(noRekorOption);
sign.Add(signOutputOption);
sign.Add(signFormatOption);
sign.SetAction((parseResult, _) =>
{
var predicatePath = parseResult.GetValue(predicateFileOption)!;
var predicateType = parseResult.GetValue(predicateTypeOption)!;
var subjectName = parseResult.GetValue(subjectNameOption)!;
var digest = parseResult.GetValue(subjectDigestOption)!;
var keyId = parseResult.GetValue(signKeyOption);
var keyless = parseResult.GetValue(keylessOption);
var useRekor = parseResult.GetValue(transparencyLogOption);
var noRekor = parseResult.GetValue(noRekorOption);
var output = parseResult.GetValue(signOutputOption);
var format = parseResult.GetValue(signFormatOption) ?? "dsse";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestSignAsync(
services,
predicatePath,
predicateType,
subjectName,
digest,
keyId,
keyless,
useRekor && !noRekor,
output,
format,
verbose,
cancellationToken);
});
// attest fetch (CLI-ATTEST-74-002)
var fetch = new Command("fetch", "Download attestation envelopes and payloads to disk.");
var fetchIdOption = new Option<string?>("--id")
{
Description = "Attestation ID to fetch."
};
var fetchSubjectOption = new Option<string?>("--subject", new[] { "-s" })
{
Description = "Subject filter (e.g., image digest, package PURL)."
};
var fetchTypeOption = new Option<string?>("--type", new[] { "-t" })
{
Description = "Predicate type filter."
};
var fetchOutputDirOption = new Option<string>("--output-dir", new[] { "-o" })
{
Description = "Output directory for downloaded files.",
Required = true
};
var fetchIncludeOption = new Option<string?>("--include")
{
Description = "What to download: envelope, payload, both (default: both)."
};
var fetchScopeOption = new Option<string?>("--scope")
{
Description = "Source scope: local, remote, all (default: all)."
};
var fetchFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format for payloads: json (default), raw."
};
var fetchOverwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing files."
};
fetch.Add(fetchIdOption);
fetch.Add(fetchSubjectOption);
fetch.Add(fetchTypeOption);
fetch.Add(fetchOutputDirOption);
fetch.Add(fetchIncludeOption);
fetch.Add(fetchScopeOption);
fetch.Add(fetchFormatOption);
fetch.Add(fetchOverwriteOption);
fetch.SetAction((parseResult, _) =>
{
var id = parseResult.GetValue(fetchIdOption);
var subject = parseResult.GetValue(fetchSubjectOption);
var type = parseResult.GetValue(fetchTypeOption);
var outputDir = parseResult.GetValue(fetchOutputDirOption)!;
var include = parseResult.GetValue(fetchIncludeOption) ?? "both";
var scope = parseResult.GetValue(fetchScopeOption) ?? "all";
var format = parseResult.GetValue(fetchFormatOption) ?? "json";
var overwrite = parseResult.GetValue(fetchOverwriteOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestFetchAsync(
services,
id,
subject,
type,
outputDir,
include,
scope,
format,
overwrite,
verbose,
cancellationToken);
});
// attest key (CLI-ATTEST-75-001)
var key = new Command("key", "Manage attestation signing keys.");
// attest key create
var keyCreate = new Command("create", "Create a new signing key for attestations.");
var keyNameOption = new Option<string>("--name", new[] { "-n" })
{
Description = "Key identifier/name.",
Required = true
};
var keyAlgorithmOption = new Option<string?>("--algorithm", new[] { "-a" })
{
Description = "Key algorithm: ECDSA-P256 (default), ECDSA-P384."
};
var keyPasswordOption = new Option<string?>("--password", new[] { "-p" })
{
Description = "Password to protect the key (required for file-based keys)."
};
var keyOutputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output path for the key directory (default: ~/.stellaops/keys)."
};
var keyFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format: table (default), json."
};
var keyExportPublicOption = new Option<bool>("--export-public")
{
Description = "Export public key to file alongside key creation."
};
keyCreate.Add(keyNameOption);
keyCreate.Add(keyAlgorithmOption);
keyCreate.Add(keyPasswordOption);
keyCreate.Add(keyOutputOption);
keyCreate.Add(keyFormatOption);
keyCreate.Add(keyExportPublicOption);
keyCreate.SetAction((parseResult, _) =>
{
var name = parseResult.GetValue(keyNameOption)!;
var algorithm = parseResult.GetValue(keyAlgorithmOption) ?? "ECDSA-P256";
var password = parseResult.GetValue(keyPasswordOption);
var output = parseResult.GetValue(keyOutputOption);
var format = parseResult.GetValue(keyFormatOption) ?? "table";
var exportPublic = parseResult.GetValue(keyExportPublicOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestKeyCreateAsync(
services,
name,
algorithm,
password,
output,
format,
exportPublic,
verbose,
cancellationToken);
});
key.Add(keyCreate);
// attest bundle (CLI-ATTEST-75-002)
var bundle = new Command("bundle", "Build and verify attestation bundles.");
// attest bundle build
var bundleBuild = new Command("build", "Build an audit bundle from artifacts (attestations, SBOMs, VEX, scans).");
var bundleSubjectNameOption = new Option<string>("--subject-name", new[] { "-s" })
{
Description = "Primary subject name (e.g., image reference).",
Required = true
};
var bundleSubjectDigestOption = new Option<string>("--subject-digest", new[] { "-d" })
{
Description = "Subject digest in algorithm:hex format (e.g., sha256:abc123...).",
Required = true
};
var bundleSubjectTypeOption = new Option<string?>("--subject-type")
{
Description = "Subject type: IMAGE (default), REPO, SBOM, OTHER."
};
var bundleInputDirOption = new Option<string>("--input", new[] { "-i" })
{
Description = "Input directory containing artifacts to bundle.",
Required = true
};
var bundleOutputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output path for the bundle (directory or .tar.gz file).",
Required = true
};
var bundleFromOption = new Option<string?>("--from")
{
Description = "Start of time window for artifacts (ISO-8601)."
};
var bundleToOption = new Option<string?>("--to")
{
Description = "End of time window for artifacts (ISO-8601)."
};
var bundleIncludeOption = new Option<string?>("--include")
{
Description = "Artifact types to include: attestations,sboms,vex,scans,policy,all (default: all)."
};
var bundleCompressOption = new Option<bool>("--compress")
{
Description = "Compress output as tar.gz."
};
var bundleCreatorIdOption = new Option<string?>("--creator-id")
{
Description = "Creator user ID (default: current user)."
};
var bundleCreatorNameOption = new Option<string?>("--creator-name")
{
Description = "Creator display name (default: current user)."
};
var bundleFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format: table (default), json."
};
bundleBuild.Add(bundleSubjectNameOption);
bundleBuild.Add(bundleSubjectDigestOption);
bundleBuild.Add(bundleSubjectTypeOption);
bundleBuild.Add(bundleInputDirOption);
bundleBuild.Add(bundleOutputOption);
bundleBuild.Add(bundleFromOption);
bundleBuild.Add(bundleToOption);
bundleBuild.Add(bundleIncludeOption);
bundleBuild.Add(bundleCompressOption);
bundleBuild.Add(bundleCreatorIdOption);
bundleBuild.Add(bundleCreatorNameOption);
bundleBuild.Add(bundleFormatOption);
bundleBuild.SetAction((parseResult, _) =>
{
var subjectName = parseResult.GetValue(bundleSubjectNameOption)!;
var subjectDigest = parseResult.GetValue(bundleSubjectDigestOption)!;
var subjectType = parseResult.GetValue(bundleSubjectTypeOption) ?? "IMAGE";
var inputDir = parseResult.GetValue(bundleInputDirOption)!;
var output = parseResult.GetValue(bundleOutputOption)!;
var from = parseResult.GetValue(bundleFromOption);
var to = parseResult.GetValue(bundleToOption);
var include = parseResult.GetValue(bundleIncludeOption) ?? "all";
var compress = parseResult.GetValue(bundleCompressOption);
var creatorId = parseResult.GetValue(bundleCreatorIdOption);
var creatorName = parseResult.GetValue(bundleCreatorNameOption);
var format = parseResult.GetValue(bundleFormatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestBundleBuildAsync(
services,
subjectName,
subjectDigest,
subjectType,
inputDir,
output,
from,
to,
include,
compress,
creatorId,
creatorName,
format,
verbose,
cancellationToken);
});
// attest bundle verify
var bundleVerify = new Command("verify", "Verify an attestation bundle's integrity and signatures.");
var bundleVerifyInputOption = new Option<string>("--input", new[] { "-i" })
{
Description = "Input bundle path (directory or .tar.gz file).",
Required = true
};
var bundleVerifyPolicyOption = new Option<string?>("--policy")
{
Description = "Policy file for attestation verification (JSON with requiredPredicateTypes, minimumSignatures, etc.)."
};
var bundleVerifyRootOption = new Option<string?>("--root")
{
Description = "Trust root file (PEM certificate or public key) for signature verification."
};
var bundleVerifyOutputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write verification report to file (JSON format)."
};
var bundleVerifyFormatOption = new Option<string?>("--format", new[] { "-f" })
{
Description = "Output format: table (default), json."
};
var bundleVerifyStrictOption = new Option<bool>("--strict")
{
Description = "Treat warnings as errors (exit code 1 on any issue)."
};
bundleVerify.Add(bundleVerifyInputOption);
bundleVerify.Add(bundleVerifyPolicyOption);
bundleVerify.Add(bundleVerifyRootOption);
bundleVerify.Add(bundleVerifyOutputOption);
bundleVerify.Add(bundleVerifyFormatOption);
bundleVerify.Add(bundleVerifyStrictOption);
bundleVerify.SetAction((parseResult, _) =>
{
var input = parseResult.GetValue(bundleVerifyInputOption)!;
var policy = parseResult.GetValue(bundleVerifyPolicyOption);
var root = parseResult.GetValue(bundleVerifyRootOption);
var output = parseResult.GetValue(bundleVerifyOutputOption);
var format = parseResult.GetValue(bundleVerifyFormatOption) ?? "table";
var strict = parseResult.GetValue(bundleVerifyStrictOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAttestBundleVerifyAsync(
services,
input,
policy,
root,
output,
format,
strict,
verbose,
cancellationToken);
});
bundle.Add(bundleBuild);
bundle.Add(bundleVerify);
attest.Add(sign);
attest.Add(verify);
attest.Add(list);
attest.Add(show);
attest.Add(fetch);
attest.Add(key);
attest.Add(bundle);
return attest;
}
@@ -9835,4 +10261,238 @@ internal static class CommandFactory
return mirror;
}
private static Command BuildAirgapCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var airgap = new Command("airgap", "Manage air-gapped environment operations.");
// airgap import (CLI-AIRGAP-57-001)
var import = new Command("import", "Import an air-gap mirror bundle into the local data store.");
var bundlePathOption = new Option<string>("--bundle", new[] { "-b" })
{
Description = "Path to the bundle directory (contains manifest.json and artifacts).",
Required = true
};
var importTenantOption = new Option<string?>("--tenant")
{
Description = "Import data under a specific tenant scope."
};
var globalOption = new Option<bool>("--global")
{
Description = "Import data to the global scope (requires elevated permissions)."
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Preview the import without making changes."
};
var forceOption = new Option<bool>("--force")
{
Description = "Force import even if checksums have been verified before."
};
var verifyOnlyOption = new Option<bool>("--verify-only")
{
Description = "Verify bundle integrity without importing."
};
var importJsonOption = new Option<bool>("--json")
{
Description = "Output results in JSON format."
};
import.Add(bundlePathOption);
import.Add(importTenantOption);
import.Add(globalOption);
import.Add(dryRunOption);
import.Add(forceOption);
import.Add(verifyOnlyOption);
import.Add(importJsonOption);
import.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundlePathOption)!;
var tenant = parseResult.GetValue(importTenantOption);
var global = parseResult.GetValue(globalOption);
var dryRun = parseResult.GetValue(dryRunOption);
var force = parseResult.GetValue(forceOption);
var verifyOnly = parseResult.GetValue(verifyOnlyOption);
var json = parseResult.GetValue(importJsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAirgapImportAsync(
services,
bundlePath,
tenant,
global,
dryRun,
force,
verifyOnly,
json,
verbose,
cancellationToken);
});
airgap.Add(import);
// airgap seal (CLI-AIRGAP-57-002)
var seal = new Command("seal", "Seal the environment for air-gapped operation.");
var sealConfigDirOption = new Option<string?>("--config-dir", new[] { "-c" })
{
Description = "Path to the configuration directory (defaults to ~/.stellaops)."
};
var sealVerifyOption = new Option<bool>("--verify")
{
Description = "Verify imported bundles before sealing."
};
var sealForceOption = new Option<bool>("--force")
{
Description = "Force seal even if verification warnings exist."
};
var sealDryRunOption = new Option<bool>("--dry-run")
{
Description = "Preview the seal operation without making changes."
};
var sealJsonOption = new Option<bool>("--json")
{
Description = "Output results in JSON format."
};
var sealReasonOption = new Option<string?>("--reason")
{
Description = "Reason for sealing (recorded in audit log)."
};
seal.Add(sealConfigDirOption);
seal.Add(sealVerifyOption);
seal.Add(sealForceOption);
seal.Add(sealDryRunOption);
seal.Add(sealJsonOption);
seal.Add(sealReasonOption);
seal.SetAction((parseResult, _) =>
{
var configDir = parseResult.GetValue(sealConfigDirOption);
var verify = parseResult.GetValue(sealVerifyOption);
var force = parseResult.GetValue(sealForceOption);
var dryRun = parseResult.GetValue(sealDryRunOption);
var json = parseResult.GetValue(sealJsonOption);
var reason = parseResult.GetValue(sealReasonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAirgapSealAsync(
services,
configDir,
verify,
force,
dryRun,
json,
reason,
verbose,
cancellationToken);
});
airgap.Add(seal);
// airgap export-evidence (CLI-AIRGAP-58-001)
var exportEvidence = new Command("export-evidence", "Export portable evidence packages for audit and compliance.");
var evidenceOutputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output directory for the evidence package.",
Required = true
};
var evidenceIncludeOption = new Option<string[]>("--include", new[] { "-i" })
{
Description = "Evidence types to include: attestations, sboms, scans, vex, all (default: all).",
AllowMultipleArgumentsPerToken = true
};
var evidenceFromOption = new Option<DateTimeOffset?>("--from")
{
Description = "Include evidence from this date (UTC, ISO-8601)."
};
var evidenceToOption = new Option<DateTimeOffset?>("--to")
{
Description = "Include evidence up to this date (UTC, ISO-8601)."
};
var evidenceTenantOption = new Option<string?>("--tenant")
{
Description = "Export evidence for a specific tenant."
};
var evidenceSubjectOption = new Option<string?>("--subject")
{
Description = "Filter evidence by subject (e.g., image digest, package PURL)."
};
var evidenceCompressOption = new Option<bool>("--compress")
{
Description = "Compress the output package as a .tar.gz archive."
};
var evidenceJsonOption = new Option<bool>("--json")
{
Description = "Output results in JSON format."
};
var evidenceVerifyOption = new Option<bool>("--verify")
{
Description = "Verify evidence signatures before export."
};
exportEvidence.Add(evidenceOutputOption);
exportEvidence.Add(evidenceIncludeOption);
exportEvidence.Add(evidenceFromOption);
exportEvidence.Add(evidenceToOption);
exportEvidence.Add(evidenceTenantOption);
exportEvidence.Add(evidenceSubjectOption);
exportEvidence.Add(evidenceCompressOption);
exportEvidence.Add(evidenceJsonOption);
exportEvidence.Add(evidenceVerifyOption);
exportEvidence.SetAction((parseResult, _) =>
{
var output = parseResult.GetValue(evidenceOutputOption)!;
var include = parseResult.GetValue(evidenceIncludeOption) ?? Array.Empty<string>();
var from = parseResult.GetValue(evidenceFromOption);
var to = parseResult.GetValue(evidenceToOption);
var tenant = parseResult.GetValue(evidenceTenantOption);
var subject = parseResult.GetValue(evidenceSubjectOption);
var compress = parseResult.GetValue(evidenceCompressOption);
var json = parseResult.GetValue(evidenceJsonOption);
var verify = parseResult.GetValue(evidenceVerifyOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAirgapExportEvidenceAsync(
services,
output,
include,
from,
to,
tenant,
subject,
compress,
json,
verify,
verbose,
cancellationToken);
});
airgap.Add(exportEvidence);
return airgap;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-ATTEST-73-001: Attestor SDK transport contract models
// Based on docs/schemas/attestor-transport.schema.json
/// <summary>
/// Request to create an attestation.
/// </summary>
internal sealed class AttestationRequest
{
[JsonPropertyName("requestType")]
public string RequestType { get; init; } = "CREATE_ATTESTATION";
[JsonPropertyName("requestId")]
public string RequestId { get; init; } = Guid.NewGuid().ToString();
[JsonPropertyName("correlationId")]
public string? CorrelationId { get; init; }
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = string.Empty;
[JsonPropertyName("subject")]
public IReadOnlyList<AttestationSubjectDto> Subject { get; init; } = Array.Empty<AttestationSubjectDto>();
[JsonPropertyName("predicate")]
public object Predicate { get; init; } = new { };
[JsonPropertyName("signingOptions")]
public SigningOptionsDto? SigningOptions { get; init; }
}
/// <summary>
/// Response for attestation creation.
/// </summary>
internal sealed class AttestationResponseDto
{
[JsonPropertyName("responseType")]
public string ResponseType { get; init; } = string.Empty;
[JsonPropertyName("requestId")]
public string RequestId { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("attestation")]
public AttestationEnvelopeDto? Attestation { get; init; }
[JsonPropertyName("error")]
public AttestationErrorDto? Error { get; init; }
}
/// <summary>
/// Subject for attestation.
/// </summary>
internal sealed class AttestationSubjectDto
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public Dictionary<string, string> Digest { get; init; } = new();
}
/// <summary>
/// Signing options for attestation.
/// </summary>
internal sealed class SigningOptionsDto
{
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("provider")]
public string? Provider { get; init; }
[JsonPropertyName("algorithm")]
public string? Algorithm { get; init; }
[JsonPropertyName("transparencyLog")]
public bool TransparencyLog { get; init; }
[JsonPropertyName("timestampAuthority")]
public string? TimestampAuthority { get; init; }
}
/// <summary>
/// DSSE attestation envelope from response.
/// </summary>
internal sealed class AttestationEnvelopeDto
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = "application/vnd.in-toto+json";
[JsonPropertyName("payload")]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
[JsonPropertyName("envelopeDigest")]
public string? EnvelopeDigest { get; init; }
[JsonPropertyName("transparencyLogEntry")]
public TransparencyLogEntryDto? TransparencyLogEntry { get; init; }
}
/// <summary>
/// DSSE signature.
/// </summary>
internal sealed class DsseSignatureDto
{
[JsonPropertyName("keyid")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("sig")]
public string Sig { get; init; } = string.Empty;
}
/// <summary>
/// Transparency log entry from Rekor.
/// </summary>
internal sealed class TransparencyLogEntryDto
{
[JsonPropertyName("logIndex")]
public long LogIndex { get; init; }
[JsonPropertyName("logId")]
public string? LogId { get; init; }
[JsonPropertyName("integratedTime")]
public DateTimeOffset? IntegratedTime { get; init; }
[JsonPropertyName("inclusionProof")]
public string? InclusionProof { get; init; }
[JsonPropertyName("entryUri")]
public string? EntryUri { get; init; }
}
/// <summary>
/// Error from attestation operation.
/// </summary>
internal sealed class AttestationErrorDto
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("details")]
public Dictionary<string, object>? Details { get; init; }
}
/// <summary>
/// Options for the attest sign command.
/// </summary>
internal sealed class AttestSignOptions
{
public string PredicatePath { get; init; } = string.Empty;
public string PredicateType { get; init; } = string.Empty;
public string SubjectName { get; init; } = string.Empty;
public string SubjectDigest { get; init; } = string.Empty;
public string? KeyId { get; init; }
public bool Keyless { get; init; }
public bool UseRekor { get; init; }
public string? OutputPath { get; init; }
public string Format { get; init; } = "dsse";
}
/// <summary>
/// Result from attest sign command.
/// </summary>
internal sealed class AttestSignResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("envelopePath")]
public string? EnvelopePath { get; init; }
[JsonPropertyName("envelopeDigest")]
public string? EnvelopeDigest { get; init; }
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = string.Empty;
[JsonPropertyName("subjectName")]
public string SubjectName { get; init; } = string.Empty;
[JsonPropertyName("subjectDigest")]
public string SubjectDigest { get; init; } = string.Empty;
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("transparencyLogEntry")]
public TransparencyLogEntryDto? TransparencyLogEntry { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}

View File

@@ -58,6 +58,8 @@
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">

View File

@@ -19,6 +19,12 @@ internal static class CliMetrics
/// </summary>
public static string SealedModePhaseLabel { get; set; } = "AirGapped-Phase-1";
/// <summary>
/// Creates a metric tag (KeyValuePair) for use with counters/histograms.
/// </summary>
private static KeyValuePair<string, object?> Tag(string key, object? value)
=> new(key, value);
/// <summary>
/// Appends sealed mode tags to the given tags array if in sealed mode.
/// </summary>
@@ -56,102 +62,116 @@ internal static class CliMetrics
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
private static readonly Counter<long> AttestSignCounter = Meter.CreateCounter<long>("stellaops.cli.attest.sign.count");
private static readonly Counter<long> AttestVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.attest.verify.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
=> ScannerDownloadCounter.Add(1, WithSealedModeTag(
new("channel", channel),
new("cache", fromCache ? "hit" : "miss")));
Tag("channel", channel),
Tag("cache", fromCache ? "hit" : "miss")));
public static void RecordScannerInstall(string channel)
=> ScannerInstallCounter.Add(1, WithSealedModeTag(new("channel", channel)));
=> ScannerInstallCounter.Add(1, WithSealedModeTag(Tag("channel", channel)));
public static void RecordScanRun(string runner, int exitCode)
=> ScanRunCounter.Add(1, WithSealedModeTag(
new("runner", runner),
new("exit_code", exitCode)));
Tag("runner", runner),
Tag("exit_code", exitCode)));
public static void RecordOfflineKitDownload(string kind, bool fromCache)
=> OfflineKitDownloadCounter.Add(1, WithSealedModeTag(
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
new("cache", fromCache ? "hit" : "miss")));
Tag("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
Tag("cache", fromCache ? "hit" : "miss")));
public static void RecordOfflineKitImport(string? status)
=> OfflineKitImportCounter.Add(1, WithSealedModeTag(
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)));
Tag("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)));
public static void RecordPolicySimulation(string outcome)
=> PolicySimulationCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordTaskRunnerSimulation(string outcome)
=> TaskRunnerSimulationCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPolicyActivation(string outcome)
=> PolicyActivationCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordAdvisoryRun(string taskType, string outcome)
=> AdvisoryRunCounter.Add(1, WithSealedModeTag(
new("task", string.IsNullOrWhiteSpace(taskType) ? "unknown" : taskType.ToLowerInvariant()),
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("task", string.IsNullOrWhiteSpace(taskType) ? "unknown" : taskType.ToLowerInvariant()),
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, WithSealedModeTag(
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)));
Tag("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)));
public static void RecordAocVerify(string outcome)
=> AocVerifyCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPolicyFindingsList(string outcome)
=> PolicyFindingsListCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPolicyFindingsGet(string outcome)
=> PolicyFindingsGetCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPolicyFindingsExplain(string outcome)
=> PolicyFindingsExplainCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordNodeLockValidate(string outcome)
=> NodeLockValidateCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPythonLockValidate(string outcome)
=> PythonLockValidateCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordJavaLockValidate(string outcome)
=> JavaLockValidateCounter.Add(1, WithSealedModeTag(
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordRubyInspect(string outcome)
=> RubyInspectCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
=> RubyInspectCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordRubyResolve(string outcome)
=> RubyResolveCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
=> RubyResolveCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPhpInspect(string outcome)
=> PhpInspectCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
=> PhpInspectCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static void RecordPythonInspect(string outcome)
=> PythonInspectCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
=> PythonInspectCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
/// <summary>
/// Records a successful attestation signing operation (CLI-ATTEST-73-001).
/// </summary>
/// <param name="predicateType">The predicate type URI.</param>
/// <param name="signingMode">The signing mode (keyed, keyless).</param>
/// <param name="rekorSubmitted">Whether the attestation was submitted to Rekor.</param>
public static void AttestSignCompleted(string predicateType, string signingMode, bool rekorSubmitted)
=> AttestSignCounter.Add(1, WithSealedModeTag(
Tag("predicate_type", string.IsNullOrWhiteSpace(predicateType) ? "unknown" : predicateType),
Tag("signing_mode", string.IsNullOrWhiteSpace(signingMode) ? "unknown" : signingMode),
Tag("rekor_submitted", rekorSubmitted.ToString().ToLowerInvariant())));
/// <summary>
/// Records an attestation verification operation.
/// </summary>
/// <param name="outcome">The verification outcome.</param>
public static void RecordAttestVerify(string outcome)
=> AttestVerifyCounter.Add(1, WithSealedModeTag(
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
public static IDisposable MeasureCommandDuration(string command)
{
@@ -180,7 +200,7 @@ internal static class CliMetrics
_disposed = true;
var elapsed = (DateTime.UtcNow - _start).TotalMilliseconds;
CommandDurationHistogram.Record(elapsed, new KeyValuePair<string, object?>[] { new("command", _command) });
CommandDurationHistogram.Record(elapsed, WithSealedModeTag(Tag("command", _command)));
}
}
}

View File

@@ -0,0 +1 @@
33ae97923c3d3f0da86474cbf5cd9318d94d0bb39ad71ff892e3a786ae264925 src/DevPortal/StellaOps.DevPortal.Site/snippets/./README.stub

View File

@@ -0,0 +1,4 @@
# DevPortal SDK Snippets (Wave B placeholder)
Place language-specific snippet packs here when delivered (e.g., `node/`, `python/`, `java/`).
Keep filenames stable and deterministic; run `tools/devportal/hash-snippets.sh` to update SHA256SUMS.devportal-stubs after drops.

View File

@@ -0,0 +1,28 @@
using StellaOps.Gateway.WebService.Middleware;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Extension methods for configuring the gateway middleware pipeline.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Adds the gateway router middleware pipeline.
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder UseGatewayRouter(this IApplicationBuilder app)
{
// Resolve endpoints from routing state
app.UseMiddleware<EndpointResolutionMiddleware>();
// Make routing decisions (select instance)
app.UseMiddleware<RoutingDecisionMiddleware>();
// Dispatch to transport and return response
app.UseMiddleware<TransportDispatchMiddleware>();
return app;
}
}

View File

@@ -0,0 +1,140 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Background service that periodically refreshes claims from Authority.
/// </summary>
internal sealed class AuthorityClaimsRefreshService : BackgroundService
{
private readonly IAuthorityClaimsProvider _claimsProvider;
private readonly IEffectiveClaimsStore _claimsStore;
private readonly AuthorityConnectionOptions _options;
private readonly ILogger<AuthorityClaimsRefreshService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityClaimsRefreshService"/> class.
/// </summary>
public AuthorityClaimsRefreshService(
IAuthorityClaimsProvider claimsProvider,
IEffectiveClaimsStore claimsStore,
IOptions<AuthorityConnectionOptions> options,
ILogger<AuthorityClaimsRefreshService> logger)
{
_claimsProvider = claimsProvider;
_claimsStore = claimsStore;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Authority integration is disabled");
return;
}
if (string.IsNullOrWhiteSpace(_options.AuthorityUrl))
{
_logger.LogWarning("Authority URL not configured, skipping claims refresh");
return;
}
// Subscribe to push notifications if enabled
if (_options.UseAuthorityPushNotifications)
{
_claimsProvider.OverridesChanged += OnOverridesChanged;
}
// Initial fetch with optional wait
await FetchWithRetryAsync(stoppingToken);
// Periodic refresh
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_options.RefreshInterval, stoppingToken);
await RefreshClaimsAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during claims refresh");
}
}
}
private async Task FetchWithRetryAsync(CancellationToken stoppingToken)
{
if (!_options.WaitForAuthorityOnStartup)
{
await RefreshClaimsAsync(stoppingToken);
return;
}
var deadline = DateTime.UtcNow.Add(_options.StartupTimeout);
var retryDelay = TimeSpan.FromSeconds(1);
var attempt = 0;
while (DateTime.UtcNow < deadline && !stoppingToken.IsCancellationRequested)
{
attempt++;
_logger.LogDebug("Fetching claims from Authority (attempt {Attempt})", attempt);
await RefreshClaimsAsync(stoppingToken);
if (_claimsProvider.IsAvailable)
{
_logger.LogInformation(
"Successfully connected to Authority after {Attempts} attempts",
attempt);
return;
}
await Task.Delay(retryDelay, stoppingToken);
retryDelay = TimeSpan.FromSeconds(Math.Min(retryDelay.TotalSeconds * 2, 10));
}
_logger.LogWarning(
"Could not connect to Authority within {Timeout}. Proceeding without Authority claims.",
_options.StartupTimeout);
}
private async Task RefreshClaimsAsync(CancellationToken cancellationToken)
{
try
{
var overrides = await _claimsProvider.GetOverridesAsync(cancellationToken);
_claimsStore.UpdateFromAuthority(overrides);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh claims from Authority");
}
}
private void OnOverridesChanged(object? sender, ClaimsOverrideChangedEventArgs e)
{
_logger.LogInformation("Received claims override update from Authority");
_claimsStore.UpdateFromAuthority(e.Overrides);
}
/// <inheritdoc />
public override void Dispose()
{
if (_options.UseAuthorityPushNotifications)
{
_claimsProvider.OverridesChanged -= OnOverridesChanged;
}
base.Dispose();
}
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Configuration options for connecting to the Authority service.
/// </summary>
public sealed class AuthorityConnectionOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Authority";
/// <summary>
/// Gets or sets the Authority service URL.
/// </summary>
public string AuthorityUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether to wait for Authority on startup.
/// If true, the gateway will delay handling traffic until Authority is available.
/// </summary>
public bool WaitForAuthorityOnStartup { get; set; } = true;
/// <summary>
/// Gets or sets the startup timeout when waiting for Authority.
/// </summary>
public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the interval at which to refresh claims from Authority.
/// </summary>
public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets whether to use push notifications from Authority.
/// If false, the gateway will poll at the refresh interval.
/// </summary>
public bool UseAuthorityPushNotifications { get; set; }
/// <summary>
/// Gets or sets whether Authority integration is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Middleware that enforces claims requirements for endpoints.
/// </summary>
public sealed class AuthorizationMiddleware
{
private readonly RequestDelegate _next;
private readonly IEffectiveClaimsStore _claimsStore;
private readonly ILogger<AuthorizationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorizationMiddleware"/> class.
/// </summary>
public AuthorizationMiddleware(
RequestDelegate next,
IEffectiveClaimsStore claimsStore,
ILogger<AuthorizationMiddleware> logger)
{
_next = next;
_claimsStore = claimsStore;
_logger = logger;
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task InvokeAsync(HttpContext context)
{
// Get resolved endpoint from earlier middleware
if (!context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) ||
endpointObj is not EndpointDescriptor endpoint)
{
// No endpoint resolved, let next middleware handle
await _next(context);
return;
}
// Get effective claims for this endpoint
var effectiveClaims = _claimsStore.GetEffectiveClaims(
endpoint.ServiceName,
endpoint.Method,
endpoint.Path);
if (effectiveClaims.Count == 0)
{
// No claims required
await _next(context);
return;
}
// Check each required claim
foreach (var required in effectiveClaims)
{
var userClaims = context.User.Claims;
bool hasClaim = required.Value == null
? userClaims.Any(c => c.Type == required.Type)
: userClaims.Any(c => c.Type == required.Type && c.Value == required.Value);
if (!hasClaim)
{
_logger.LogWarning(
"Authorization failed for {Method} {Path}: user lacks claim {ClaimType}={ClaimValue}",
endpoint.Method,
endpoint.Path,
required.Type,
required.Value ?? "(any)");
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "Forbidden",
message = "Authorization failed: missing required claim",
requiredClaim = new { type = required.Type, value = required.Value }
});
return;
}
}
await _next(context);
}
}
/// <summary>
/// Extension methods for registering the authorization middleware.
/// </summary>
public static class AuthorizationMiddlewareExtensions
{
/// <summary>
/// Adds the claims authorization middleware to the pipeline.
/// </summary>
/// <param name="app">The application builder.</param>
/// <returns>The application builder for chaining.</returns>
public static IApplicationBuilder UseClaimsAuthorization(this IApplicationBuilder app)
{
return app.UseMiddleware<AuthorizationMiddleware>();
}
}

View File

@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// In-memory store for effective claims.
/// Merges microservice defaults with Authority overrides.
/// </summary>
internal sealed class EffectiveClaimsStore : IEffectiveClaimsStore
{
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _microserviceClaims = new();
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authorityClaims = new();
private readonly ILogger<EffectiveClaimsStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EffectiveClaimsStore"/> class.
/// </summary>
public EffectiveClaimsStore(ILogger<EffectiveClaimsStore> logger)
{
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
{
var key = EndpointKey.Create(serviceName, method, path);
// Authority takes precedence
if (_authorityClaims.TryGetValue(key, out var authorityClaims))
{
_logger.LogDebug(
"Using Authority claims for {Endpoint}: {ClaimCount} claims",
key,
authorityClaims.Count);
return authorityClaims;
}
// Fall back to microservice defaults
if (_microserviceClaims.TryGetValue(key, out var msClaims))
{
return msClaims;
}
return [];
}
/// <inheritdoc />
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
foreach (var endpoint in endpoints)
{
var key = EndpointKey.Create(serviceName, endpoint.Method, endpoint.Path);
var claims = endpoint.RequiringClaims ?? [];
if (claims.Count > 0)
{
_microserviceClaims[key] = claims;
_logger.LogDebug(
"Registered {ClaimCount} claims from microservice for {Endpoint}",
claims.Count,
key);
}
else
{
_microserviceClaims.TryRemove(key, out _);
}
}
}
/// <inheritdoc />
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
// Clear previous Authority claims
_authorityClaims.Clear();
// Add new Authority claims
foreach (var (key, claims) in overrides)
{
if (claims.Count > 0)
{
_authorityClaims[key] = claims;
}
}
_logger.LogInformation(
"Updated Authority claims: {EndpointCount} endpoints with overrides",
overrides.Count);
}
/// <inheritdoc />
public void RemoveService(string serviceName)
{
var normalizedServiceName = serviceName.ToLowerInvariant();
var keysToRemove = _microserviceClaims.Keys
.Where(k => k.ServiceName == normalizedServiceName)
.ToList();
foreach (var key in keysToRemove)
{
_microserviceClaims.TryRemove(key, out _);
}
_logger.LogDebug(
"Removed {Count} endpoint claims for service {ServiceName}",
keysToRemove.Count,
serviceName);
}
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Key for identifying an endpoint by service name, method, and path.
/// </summary>
/// <param name="ServiceName">The name of the service.</param>
/// <param name="Method">The HTTP method.</param>
/// <param name="Path">The path template.</param>
public readonly record struct EndpointKey(string ServiceName, string Method, string Path)
{
/// <summary>
/// Creates an endpoint key with normalized values.
/// </summary>
public static EndpointKey Create(string serviceName, string method, string path)
{
return new EndpointKey(
serviceName.ToLowerInvariant(),
method.ToUpperInvariant(),
path.ToLowerInvariant());
}
/// <inheritdoc />
public override string ToString() => $"{ServiceName}:{Method} {Path}";
}

View File

@@ -0,0 +1,133 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Fetches claims overrides from the Authority service via HTTP.
/// </summary>
internal sealed class HttpAuthorityClaimsProvider : IAuthorityClaimsProvider
{
private readonly HttpClient _httpClient;
private readonly AuthorityConnectionOptions _options;
private readonly ILogger<HttpAuthorityClaimsProvider> _logger;
private volatile bool _isAvailable;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Initializes a new instance of the <see cref="HttpAuthorityClaimsProvider"/> class.
/// </summary>
public HttpAuthorityClaimsProvider(
HttpClient httpClient,
IOptions<AuthorityConnectionOptions> options,
ILogger<HttpAuthorityClaimsProvider> logger)
{
_httpClient = httpClient;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public bool IsAvailable => _isAvailable;
/// <inheritdoc />
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
/// <inheritdoc />
public async Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.AuthorityUrl))
{
_logger.LogDebug("Authority URL not configured, returning empty overrides");
_isAvailable = false;
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
}
try
{
var url = $"{_options.AuthorityUrl.TrimEnd('/')}/api/v1/claims/overrides";
_logger.LogDebug("Fetching claims overrides from {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var overrideResponse = await response.Content.ReadFromJsonAsync<ClaimsOverrideResponse>(
JsonOptions,
cancellationToken);
if (overrideResponse?.Overrides == null)
{
_isAvailable = true;
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
}
var result = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
foreach (var entry in overrideResponse.Overrides)
{
var key = EndpointKey.Create(entry.ServiceName, entry.Method, entry.Path);
var claims = entry.RequiringClaims
.Select(c => new ClaimRequirement { Type = c.Type, Value = c.Value })
.ToList();
result[key] = claims;
}
_isAvailable = true;
_logger.LogInformation(
"Fetched {Count} claims overrides from Authority",
result.Count);
return result;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_isAvailable = false;
_logger.LogWarning(ex, "Failed to fetch claims overrides from Authority");
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
}
}
/// <summary>
/// Raises the <see cref="OverridesChanged"/> event.
/// </summary>
internal void RaiseOverridesChanged(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
OverridesChanged?.Invoke(this, new ClaimsOverrideChangedEventArgs { Overrides = overrides });
}
/// <summary>
/// DTO for claims override response from Authority.
/// </summary>
private sealed class ClaimsOverrideResponse
{
public List<ClaimsOverrideEntry> Overrides { get; set; } = [];
}
/// <summary>
/// DTO for a single claims override entry.
/// </summary>
private sealed class ClaimsOverrideEntry
{
public string ServiceName { get; set; } = string.Empty;
public string Method { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public List<ClaimRequirementDto> RequiringClaims { get; set; } = [];
}
/// <summary>
/// DTO for a claim requirement.
/// </summary>
private sealed class ClaimRequirementDto
{
public string Type { get; set; } = string.Empty;
public string? Value { get; set; }
}
}

View File

@@ -0,0 +1,39 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Provides claims overrides from the central Authority service.
/// </summary>
public interface IAuthorityClaimsProvider
{
/// <summary>
/// Gets all claims overrides from Authority.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A dictionary of endpoint keys to claim requirements.</returns>
Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
CancellationToken cancellationToken);
/// <summary>
/// Gets a value indicating whether the Authority is currently available.
/// </summary>
bool IsAvailable { get; }
/// <summary>
/// Occurs when claims overrides change.
/// </summary>
event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
}
/// <summary>
/// Event arguments for claims override changes.
/// </summary>
public sealed class ClaimsOverrideChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the updated claims overrides.
/// </summary>
public IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> Overrides { get; init; }
= new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
}

View File

@@ -0,0 +1,40 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Stores and retrieves effective claims for endpoints.
/// Handles merging of microservice defaults with Authority overrides.
/// </summary>
public interface IEffectiveClaimsStore
{
/// <summary>
/// Gets the effective claims for an endpoint.
/// Authority overrides take precedence over microservice defaults.
/// </summary>
/// <param name="serviceName">The service name.</param>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The path template.</param>
/// <returns>The effective claims for the endpoint.</returns>
IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path);
/// <summary>
/// Updates claims from a microservice's HELLO message.
/// </summary>
/// <param name="serviceName">The service name.</param>
/// <param name="endpoints">The endpoint descriptors with claims.</param>
void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints);
/// <summary>
/// Updates claims from Authority overrides.
/// </summary>
/// <param name="overrides">The Authority claims overrides.</param>
void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides);
/// <summary>
/// Removes all claims for a service.
/// Called when a microservice disconnects.
/// </summary>
/// <param name="serviceName">The service name.</param>
void RemoveService(string serviceName);
}

View File

@@ -0,0 +1,107 @@
namespace StellaOps.Gateway.WebService.Authorization;
/// <summary>
/// Extension methods for registering Authority integration services.
/// </summary>
public static class AuthorizationServiceCollectionExtensions
{
/// <summary>
/// Adds Authority integration services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAuthorityIntegration(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind options
services.Configure<AuthorityConnectionOptions>(
configuration.GetSection(AuthorityConnectionOptions.SectionName));
// Register effective claims store
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
// Register HTTP client for Authority
services.AddHttpClient<IAuthorityClaimsProvider, HttpAuthorityClaimsProvider>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register background service for claims refresh
services.AddHostedService<AuthorityClaimsRefreshService>();
return services;
}
/// <summary>
/// Adds Authority integration services with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure Authority options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAuthorityIntegration(
this IServiceCollection services,
Action<AuthorityConnectionOptions>? configure = null)
{
// Register options
if (configure != null)
{
services.Configure(configure);
}
else
{
services.AddOptions<AuthorityConnectionOptions>();
}
// Register effective claims store
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
// Register HTTP client for Authority
services.AddHttpClient<IAuthorityClaimsProvider, HttpAuthorityClaimsProvider>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register background service for claims refresh
services.AddHostedService<AuthorityClaimsRefreshService>();
return services;
}
/// <summary>
/// Adds a no-op Authority integration (no external Authority).
/// Claims are only from microservices.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddNoOpAuthorityIntegration(this IServiceCollection services)
{
services.Configure<AuthorityConnectionOptions>(options => options.Enabled = false);
services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
services.AddSingleton<IAuthorityClaimsProvider, NoOpAuthorityClaimsProvider>();
return services;
}
}
/// <summary>
/// A no-op Authority claims provider that returns empty overrides.
/// </summary>
internal sealed class NoOpAuthorityClaimsProvider : IAuthorityClaimsProvider
{
/// <inheritdoc />
public bool IsAvailable => true;
/// <inheritdoc />
#pragma warning disable CS0067 // Event is never used (expected for no-op implementation)
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
#pragma warning restore CS0067
/// <inheritdoc />
public Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<StellaOps.Router.Common.Models.ClaimRequirement>>> GetOverridesAsync(
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyDictionary<EndpointKey, IReadOnlyList<StellaOps.Router.Common.Models.ClaimRequirement>>>(
new Dictionary<EndpointKey, IReadOnlyList<StellaOps.Router.Common.Models.ClaimRequirement>>());
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Manages microservice connections and updates routing state.
/// </summary>
internal sealed class ConnectionManager : IHostedService
{
private readonly InMemoryTransportServer _transportServer;
private readonly InMemoryConnectionRegistry _connectionRegistry;
private readonly IGlobalRoutingState _routingState;
private readonly ILogger<ConnectionManager> _logger;
public ConnectionManager(
InMemoryTransportServer transportServer,
InMemoryConnectionRegistry connectionRegistry,
IGlobalRoutingState routingState,
ILogger<ConnectionManager> logger)
{
_transportServer = transportServer;
_connectionRegistry = connectionRegistry;
_routingState = routingState;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
// Subscribe to transport server events
_transportServer.OnHelloReceived += HandleHelloReceivedAsync;
_transportServer.OnHeartbeatReceived += HandleHeartbeatReceivedAsync;
_transportServer.OnConnectionClosed += HandleConnectionClosedAsync;
// Start the transport server
await _transportServer.StartAsync(cancellationToken);
_logger.LogInformation("Connection manager started");
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await _transportServer.StopAsync(cancellationToken);
_transportServer.OnHelloReceived -= HandleHelloReceivedAsync;
_transportServer.OnHeartbeatReceived -= HandleHeartbeatReceivedAsync;
_transportServer.OnConnectionClosed -= HandleConnectionClosedAsync;
_logger.LogInformation("Connection manager stopped");
}
private Task HandleHelloReceivedAsync(ConnectionState connectionState, HelloPayload payload)
{
_logger.LogInformation(
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints",
connectionState.ConnectionId,
connectionState.Instance.ServiceName,
connectionState.Instance.Version,
connectionState.Endpoints.Count);
// Add the connection to the routing state
_routingState.AddConnection(connectionState);
// Start listening to this connection for frames
_transportServer.StartListeningToConnection(connectionState.ConnectionId);
return Task.CompletedTask;
}
private Task HandleHeartbeatReceivedAsync(ConnectionState connectionState, HeartbeatPayload payload)
{
_logger.LogDebug(
"Heartbeat received from {ConnectionId}: status={Status}",
connectionState.ConnectionId,
payload.Status);
// Update connection state
_routingState.UpdateConnection(connectionState.ConnectionId, conn =>
{
conn.Status = payload.Status;
conn.LastHeartbeatUtc = DateTime.UtcNow;
});
return Task.CompletedTask;
}
private Task HandleConnectionClosedAsync(string connectionId)
{
_logger.LogInformation("Connection closed: {ConnectionId}", connectionId);
// Remove from routing state
_routingState.RemoveConnection(connectionId);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,256 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Default implementation of routing plugin that provides health-aware, region-aware routing.
/// </summary>
/// <remarks>
/// Routing algorithm:
/// 1. Filter by ServiceName (exact match from endpoint)
/// 2. Filter by Version (strict semver equality when RequestedVersion specified)
/// 3. Filter by Health (Healthy preferred, Degraded as fallback)
/// 4. Group by Region Tier:
/// - Tier 0: Same region as gateway
/// - Tier 1: Configured neighbor regions
/// - Tier 2: All other regions
/// 5. Within each tier, sort by:
/// - Primary: Lower AveragePingMs
/// - Secondary: More recent LastHeartbeatUtc
/// - Tie-breaker: Random or RoundRobin
/// 6. Return first candidate from best available tier
/// 7. If none remain, return null (503 Service Unavailable)
/// </remarks>
internal sealed class DefaultRoutingPlugin : IRoutingPlugin
{
private readonly RoutingOptions _options;
private readonly GatewayNodeConfig _gatewayConfig;
private readonly ConcurrentDictionary<string, int> _roundRobinCounters = new();
/// <summary>
/// Initializes a new instance of the <see cref="DefaultRoutingPlugin"/> class.
/// </summary>
public DefaultRoutingPlugin(
IOptions<RoutingOptions> options,
IOptions<GatewayNodeConfig> gatewayConfig)
{
_options = options.Value;
_gatewayConfig = gatewayConfig.Value;
}
/// <inheritdoc />
public Task<RoutingDecision?> ChooseInstanceAsync(
RoutingContext context,
CancellationToken cancellationToken)
{
if (context.AvailableConnections.Count == 0)
{
return Task.FromResult<RoutingDecision?>(null);
}
var endpoint = context.Endpoint;
if (endpoint is null)
{
return Task.FromResult<RoutingDecision?>(null);
}
// Start with all available connections
var candidates = context.AvailableConnections.ToList();
// Filter by version if requested
candidates = FilterByVersion(candidates, context.RequestedVersion);
if (candidates.Count == 0)
{
return Task.FromResult<RoutingDecision?>(null);
}
// Filter by health status - prefer healthy, fall back to degraded
candidates = FilterByHealth(candidates);
if (candidates.Count == 0)
{
return Task.FromResult<RoutingDecision?>(null);
}
// Group by region tier and select from best available tier
var selected = SelectByRegionTier(candidates, context.GatewayRegion, endpoint.ServiceName);
if (selected is null)
{
return Task.FromResult<RoutingDecision?>(null);
}
var decision = new RoutingDecision
{
Endpoint = endpoint,
Connection = selected,
TransportType = selected.TransportType,
EffectiveTimeout = TimeSpan.FromMilliseconds(_options.RoutingTimeoutMs)
};
return Task.FromResult<RoutingDecision?>(decision);
}
private List<ConnectionState> FilterByVersion(
List<ConnectionState> candidates,
string? requestedVersion)
{
// Determine effective version to match
var versionToMatch = requestedVersion ?? _options.DefaultVersion;
// If no version specified and no default, return all candidates
if (string.IsNullOrEmpty(versionToMatch))
{
return candidates;
}
if (_options.StrictVersionMatching)
{
// Strict match: exact version equality
return candidates
.Where(c => string.Equals(c.Instance.Version, versionToMatch, StringComparison.Ordinal))
.ToList();
}
// Non-strict: allow compatible versions (for now, just exact match)
// Future: implement semver compatibility checking
return candidates
.Where(c => string.Equals(c.Instance.Version, versionToMatch, StringComparison.Ordinal))
.ToList();
}
private List<ConnectionState> FilterByHealth(List<ConnectionState> candidates)
{
// Filter to only healthy instances first
var healthy = candidates
.Where(c => c.Status == InstanceHealthStatus.Healthy)
.ToList();
if (healthy.Count > 0)
{
return healthy;
}
// If no healthy instances and degraded allowed, include degraded
if (_options.AllowDegradedInstances)
{
var degraded = candidates
.Where(c => c.Status == InstanceHealthStatus.Degraded)
.ToList();
if (degraded.Count > 0)
{
return degraded;
}
}
// No suitable instances
return [];
}
private ConnectionState? SelectByRegionTier(
List<ConnectionState> candidates,
string gatewayRegion,
string serviceName)
{
if (!_options.PreferLocalRegion || string.IsNullOrEmpty(gatewayRegion))
{
// No region preference, select from all candidates
return SelectFromTier(candidates, serviceName);
}
// Tier 0: Same region as gateway
var tier0 = candidates
.Where(c => string.Equals(c.Instance.Region, gatewayRegion, StringComparison.OrdinalIgnoreCase))
.ToList();
var selected = SelectFromTier(tier0, serviceName);
if (selected is not null)
{
return selected;
}
// Tier 1: Configured neighbor regions
var neighborRegions = _gatewayConfig.NeighborRegions;
if (neighborRegions.Count > 0)
{
var tier1 = candidates
.Where(c => neighborRegions.Contains(c.Instance.Region, StringComparer.OrdinalIgnoreCase))
.ToList();
selected = SelectFromTier(tier1, serviceName);
if (selected is not null)
{
return selected;
}
}
// Tier 2: All other regions (remaining candidates not in tier0 or tier1)
var tier2 = candidates
.Where(c => !string.Equals(c.Instance.Region, gatewayRegion, StringComparison.OrdinalIgnoreCase))
.Where(c => !neighborRegions.Contains(c.Instance.Region, StringComparer.OrdinalIgnoreCase))
.ToList();
return SelectFromTier(tier2, serviceName);
}
private ConnectionState? SelectFromTier(List<ConnectionState> tier, string serviceName)
{
if (tier.Count == 0)
{
return null;
}
if (tier.Count == 1)
{
return tier[0];
}
// Sort by ping (ascending), then by heartbeat (descending = more recent first)
var sorted = tier
.OrderBy(c => c.AveragePingMs)
.ThenByDescending(c => c.LastHeartbeatUtc)
.ToList();
var best = sorted[0];
// Find all instances "tied" with the best one
var tied = sorted
.TakeWhile(c =>
Math.Abs(c.AveragePingMs - best.AveragePingMs) <= _options.PingToleranceMs &&
c.LastHeartbeatUtc == best.LastHeartbeatUtc)
.ToList();
if (tied.Count == 1)
{
return tied[0];
}
// Apply tie-breaker
return _options.TieBreaker switch
{
TieBreakerMode.RoundRobin => SelectRoundRobin(tied, serviceName),
_ => SelectRandom(tied)
};
}
private ConnectionState SelectRandom(List<ConnectionState> candidates)
{
var index = Random.Shared.Next(candidates.Count);
return candidates[index];
}
private ConnectionState SelectRoundRobin(List<ConnectionState> candidates, string serviceName)
{
// Get or create counter for this service
var counter = _roundRobinCounters.AddOrUpdate(
serviceName,
_ => 0,
(_, current) => current + 1);
var index = counter % candidates.Count;
return candidates[index];
}
}

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Gateway.WebService;
/// <summary>
@@ -6,23 +8,48 @@ namespace StellaOps.Gateway.WebService;
public sealed class GatewayNodeConfig
{
/// <summary>
/// Gets the region where this gateway is deployed (e.g., "eu1").
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "GatewayNode";
/// <summary>
/// Gets or sets the region where this gateway is deployed (e.g., "eu1").
/// Routing decisions use this value; it is never derived from headers or URLs.
/// </summary>
public required string Region { get; init; }
[Required(ErrorMessage = "Region is required for gateway routing")]
public string Region { get; set; } = string.Empty;
/// <summary>
/// Gets the unique identifier for this gateway node (e.g., "gw-eu1-01").
/// Gets or sets the unique identifier for this gateway node (e.g., "gw-eu1-01").
/// </summary>
public required string NodeId { get; init; }
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// Gets the environment name (e.g., "prod", "staging", "dev").
/// Gets or sets the environment name (e.g., "prod", "staging", "dev").
/// </summary>
public required string Environment { get; init; }
public string Environment { get; set; } = string.Empty;
/// <summary>
/// Gets the neighbor regions for fallback routing, in order of preference.
/// Gets or sets the neighbor regions for fallback routing, in order of preference.
/// </summary>
public IReadOnlyList<string> NeighborRegions { get; init; } = [];
public List<string> NeighborRegions { get; set; } = [];
/// <summary>
/// Validates the configuration.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Region))
{
throw new InvalidOperationException(
$"{SectionName}:Region is required. Gateway cannot start without a region assignment.");
}
// Generate NodeId if not provided
if (string.IsNullOrWhiteSpace(NodeId))
{
NodeId = $"gw-{Region}-{Guid.NewGuid().ToString("N")[..8]}";
}
}
}

View File

@@ -0,0 +1,117 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Background service that monitors connection health and marks stale instances as unhealthy.
/// </summary>
internal sealed class HealthMonitorService : BackgroundService
{
private readonly IGlobalRoutingState _routingState;
private readonly IOptions<HealthOptions> _options;
private readonly ILogger<HealthMonitorService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="HealthMonitorService"/> class.
/// </summary>
public HealthMonitorService(
IGlobalRoutingState routingState,
IOptions<HealthOptions> options,
ILogger<HealthMonitorService> logger)
{
_routingState = routingState;
_options = options;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Health monitor started. Stale threshold: {StaleThreshold}, Check interval: {CheckInterval}",
_options.Value.StaleThreshold,
_options.Value.CheckInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_options.Value.CheckInterval, stoppingToken);
CheckStaleConnections();
}
catch (OperationCanceledException)
{
// Expected on shutdown
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in health monitor loop");
}
}
_logger.LogInformation("Health monitor stopped");
}
private void CheckStaleConnections()
{
var staleThreshold = _options.Value.StaleThreshold;
var degradedThreshold = _options.Value.DegradedThreshold;
var now = DateTime.UtcNow;
var staleCount = 0;
var degradedCount = 0;
foreach (var connection in _routingState.GetAllConnections())
{
// Skip connections that are already draining - they're intentionally stopping
if (connection.Status == InstanceHealthStatus.Draining)
{
continue;
}
var age = now - connection.LastHeartbeatUtc;
// Check for stale (no heartbeat for too long)
if (age > staleThreshold && connection.Status != InstanceHealthStatus.Unhealthy)
{
_routingState.UpdateConnection(connection.ConnectionId, c =>
c.Status = InstanceHealthStatus.Unhealthy);
_logger.LogWarning(
"Instance {InstanceId} ({ServiceName}/{Version}) marked Unhealthy: no heartbeat for {Age:g}",
connection.Instance.InstanceId,
connection.Instance.ServiceName,
connection.Instance.Version,
age);
staleCount++;
}
// Check for degraded (heartbeat delayed but not stale)
else if (age > degradedThreshold &&
connection.Status == InstanceHealthStatus.Healthy)
{
_routingState.UpdateConnection(connection.ConnectionId, c =>
c.Status = InstanceHealthStatus.Degraded);
_logger.LogWarning(
"Instance {InstanceId} ({ServiceName}/{Version}) marked Degraded: delayed heartbeat ({Age:g})",
connection.Instance.InstanceId,
connection.Instance.ServiceName,
connection.Instance.Version,
age);
degradedCount++;
}
}
if (staleCount > 0 || degradedCount > 0)
{
_logger.LogDebug(
"Health check completed: {StaleCount} stale, {DegradedCount} degraded",
staleCount,
degradedCount);
}
}
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Configuration options for health monitoring.
/// </summary>
public sealed class HealthOptions
{
/// <summary>
/// Gets the configuration section name.
/// </summary>
public const string SectionName = "Health";
/// <summary>
/// Gets or sets the threshold after which a connection is considered stale (no heartbeat).
/// Default: 30 seconds.
/// </summary>
public TimeSpan StaleThreshold { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the threshold after which a connection is considered degraded.
/// Default: 15 seconds.
/// </summary>
public TimeSpan DegradedThreshold { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>
/// Gets or sets the interval at which to check for stale connections.
/// Default: 5 seconds.
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets or sets the number of ping measurements to keep for averaging.
/// Default: 10.
/// </summary>
public int PingHistorySize { get; set; } = 10;
}

View File

@@ -0,0 +1,159 @@
using System.Collections.Concurrent;
using StellaOps.Router.Common;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// In-memory implementation of global routing state.
/// </summary>
internal sealed class InMemoryRoutingState : IGlobalRoutingState
{
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
private readonly ConcurrentDictionary<(string Method, string Path), ConcurrentBag<string>> _endpointIndex = new();
private readonly ConcurrentDictionary<(string Method, string Path), PathMatcher> _pathMatchers = new();
private readonly object _indexLock = new();
/// <inheritdoc />
public void AddConnection(ConnectionState connection)
{
_connections[connection.ConnectionId] = connection;
// Index all endpoints
foreach (var endpoint in connection.Endpoints.Values)
{
var key = (endpoint.Method, endpoint.Path);
// Add to endpoint index
var connectionIds = _endpointIndex.GetOrAdd(key, _ => []);
connectionIds.Add(connection.ConnectionId);
// Create path matcher if not exists
_pathMatchers.GetOrAdd(key, _ => new PathMatcher(endpoint.Path));
}
}
/// <inheritdoc />
public void RemoveConnection(string connectionId)
{
if (_connections.TryRemove(connectionId, out var connection))
{
// Remove from endpoint index
foreach (var endpoint in connection.Endpoints.Values)
{
var key = (endpoint.Method, endpoint.Path);
if (_endpointIndex.TryGetValue(key, out var connectionIds))
{
// ConcurrentBag doesn't support removal, so we need to rebuild
lock (_indexLock)
{
var remaining = connectionIds.Where(id => id != connectionId).ToList();
if (remaining.Count == 0)
{
_endpointIndex.TryRemove(key, out _);
_pathMatchers.TryRemove(key, out _);
}
else
{
_endpointIndex[key] = new ConcurrentBag<string>(remaining);
}
}
}
}
}
}
/// <inheritdoc />
public void UpdateConnection(string connectionId, Action<ConnectionState> update)
{
if (_connections.TryGetValue(connectionId, out var connection))
{
update(connection);
}
}
/// <inheritdoc />
public ConnectionState? GetConnection(string connectionId)
{
return _connections.TryGetValue(connectionId, out var connection) ? connection : null;
}
/// <inheritdoc />
public IReadOnlyList<ConnectionState> GetAllConnections()
{
return [.. _connections.Values];
}
/// <inheritdoc />
public EndpointDescriptor? ResolveEndpoint(string method, string path)
{
// First try exact match
foreach (var ((m, p), matcher) in _pathMatchers)
{
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
continue;
if (matcher.IsMatch(path))
{
// Get first connection with this endpoint
if (_endpointIndex.TryGetValue((m, p), out var connectionIds))
{
foreach (var connectionId in connectionIds)
{
if (_connections.TryGetValue(connectionId, out var conn) &&
conn.Endpoints.TryGetValue((m, p), out var endpoint))
{
return endpoint;
}
}
}
}
}
return null;
}
/// <inheritdoc />
public IReadOnlyList<ConnectionState> GetConnectionsFor(
string serviceName,
string version,
string method,
string path)
{
var result = new List<ConnectionState>();
foreach (var ((m, p), matcher) in _pathMatchers)
{
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
continue;
if (!matcher.IsMatch(path))
continue;
if (!_endpointIndex.TryGetValue((m, p), out var connectionIds))
continue;
foreach (var connectionId in connectionIds)
{
if (!_connections.TryGetValue(connectionId, out var conn))
continue;
// Filter by service name and version
if (!string.Equals(conn.Instance.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase))
continue;
if (!string.Equals(conn.Instance.Version, version, StringComparison.Ordinal))
continue;
// Check endpoint exists
if (conn.Endpoints.ContainsKey((m, p)))
{
result.Add(conn);
}
}
}
return result;
}
}

View File

@@ -0,0 +1,135 @@
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// A stream wrapper that counts bytes read and enforces a limit.
/// </summary>
public sealed class ByteCountingStream : Stream
{
private readonly Stream _inner;
private readonly long _limit;
private readonly Action? _onLimitExceeded;
private long _bytesRead;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="ByteCountingStream"/> class.
/// </summary>
/// <param name="inner">The inner stream to wrap.</param>
/// <param name="limit">The maximum number of bytes that can be read.</param>
/// <param name="onLimitExceeded">Optional callback invoked when the limit is exceeded.</param>
public ByteCountingStream(Stream inner, long limit, Action? onLimitExceeded = null)
{
_inner = inner;
_limit = limit;
_onLimitExceeded = onLimitExceeded;
}
/// <summary>
/// Gets the total number of bytes read from this stream.
/// </summary>
public long BytesRead => Interlocked.Read(ref _bytesRead);
/// <inheritdoc />
public override bool CanRead => _inner.CanRead;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => _inner.Length;
/// <inheritdoc />
public override long Position
{
get => _inner.Position;
set => throw new NotSupportedException("Seeking not supported on ByteCountingStream.");
}
/// <inheritdoc />
public override void Flush() => _inner.Flush();
/// <inheritdoc />
public override Task FlushAsync(CancellationToken cancellationToken) =>
_inner.FlushAsync(cancellationToken);
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
var read = _inner.Read(buffer, offset, count);
CheckLimit(read);
return read;
}
/// <inheritdoc />
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var read = await _inner.ReadAsync(buffer, offset, count, cancellationToken);
CheckLimit(read);
return read;
}
/// <inheritdoc />
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await _inner.ReadAsync(buffer, cancellationToken);
CheckLimit(read);
return read;
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException("Seeking not supported on ByteCountingStream.");
}
/// <inheritdoc />
public override void SetLength(long value)
{
throw new NotSupportedException("Setting length not supported on ByteCountingStream.");
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Writing not supported on ByteCountingStream.");
}
private void CheckLimit(int bytesJustRead)
{
if (bytesJustRead <= 0) return;
var newTotal = Interlocked.Add(ref _bytesRead, bytesJustRead);
if (newTotal > _limit)
{
_onLimitExceeded?.Invoke();
throw new PayloadLimitExceededException(newTotal, _limit);
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_inner.Dispose();
}
_disposed = true;
base.Dispose(disposing);
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
if (!_disposed)
{
await _inner.DisposeAsync();
}
_disposed = true;
await base.DisposeAsync();
}
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Router.Common.Abstractions;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Resolves incoming HTTP requests to endpoint descriptors using the routing state.
/// </summary>
public sealed class EndpointResolutionMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointResolutionMiddleware"/> class.
/// </summary>
public EndpointResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task Invoke(HttpContext context, IGlobalRoutingState routingState)
{
var method = context.Request.Method;
var path = context.Request.Path.ToString();
var endpoint = routingState.ResolveEndpoint(method, path);
if (endpoint is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new
{
error = "Endpoint not found",
method,
path
});
return;
}
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
await _next(context);
}
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Exception thrown when a payload limit is exceeded during streaming.
/// </summary>
public sealed class PayloadLimitExceededException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PayloadLimitExceededException"/> class.
/// </summary>
/// <param name="bytesRead">The number of bytes read before the limit was exceeded.</param>
/// <param name="limit">The limit that was exceeded.</param>
public PayloadLimitExceededException(long bytesRead, long limit)
: base($"Payload limit exceeded: {bytesRead} bytes read, limit is {limit} bytes")
{
BytesRead = bytesRead;
Limit = limit;
}
/// <summary>
/// Gets the number of bytes read before the limit was exceeded.
/// </summary>
public long BytesRead { get; }
/// <summary>
/// Gets the limit that was exceeded.
/// </summary>
public long Limit { get; }
}

View File

@@ -0,0 +1,162 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Middleware that enforces payload limits per-request, per-connection, and aggregate.
/// </summary>
public sealed class PayloadLimitsMiddleware
{
private readonly RequestDelegate _next;
private readonly PayloadLimits _limits;
private readonly ILogger<PayloadLimitsMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PayloadLimitsMiddleware"/> class.
/// </summary>
public PayloadLimitsMiddleware(
RequestDelegate next,
IOptions<PayloadLimits> limits,
ILogger<PayloadLimitsMiddleware> logger)
{
_next = next;
_limits = limits.Value;
_logger = logger;
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task Invoke(HttpContext context, IPayloadTracker tracker)
{
var connectionId = context.Connection.Id;
var contentLength = context.Request.ContentLength ?? 0;
// Early rejection for known oversized Content-Length (LIM-002, LIM-003)
if (context.Request.ContentLength.HasValue &&
context.Request.ContentLength.Value > _limits.MaxRequestBytesPerCall)
{
_logger.LogWarning(
"Request rejected: Content-Length {ContentLength} exceeds per-call limit {Limit}. ConnectionId: {ConnectionId}",
context.Request.ContentLength.Value,
_limits.MaxRequestBytesPerCall,
connectionId);
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
await context.Response.WriteAsJsonAsync(new
{
error = "Payload Too Large",
maxBytes = _limits.MaxRequestBytesPerCall,
contentLength = context.Request.ContentLength.Value
});
return;
}
// Try to reserve capacity (checks aggregate and per-connection limits)
if (!tracker.TryReserve(connectionId, contentLength))
{
// Check which limit was hit
if (tracker.IsOverloaded)
{
// Aggregate limit exceeded (LIM-033)
_logger.LogWarning(
"Request rejected: Aggregate limit exceeded. Current inflight: {Current}, Limit: {Limit}. ConnectionId: {ConnectionId}",
tracker.CurrentInflightBytes,
_limits.MaxAggregateInflightBytes,
connectionId);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
error = "Service Overloaded",
message = "Too many concurrent requests"
});
}
else
{
// Per-connection limit exceeded (LIM-022)
_logger.LogWarning(
"Request rejected: Per-connection limit exceeded. ConnectionId: {ConnectionId}, Current: {Current}, Limit: {Limit}",
connectionId,
tracker.GetConnectionInflightBytes(connectionId),
_limits.MaxRequestBytesPerConnection);
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.Response.WriteAsJsonAsync(new
{
error = "Too Many Requests",
message = "Per-connection limit exceeded"
});
}
return;
}
// Store the original body stream
var originalBody = context.Request.Body;
long actualBytesRead = 0;
try
{
// Wrap the request body with ByteCountingStream for streaming requests
if (!context.Request.ContentLength.HasValue || context.Request.ContentLength.Value > 0)
{
var countingStream = new ByteCountingStream(
originalBody,
_limits.MaxRequestBytesPerCall,
() =>
{
_logger.LogWarning(
"Mid-stream limit exceeded. ConnectionId: {ConnectionId}, Limit: {Limit}",
connectionId,
_limits.MaxRequestBytesPerCall);
});
context.Request.Body = countingStream;
// Store reference for later access to bytes read
context.Items["PayloadLimits:CountingStream"] = countingStream;
}
await _next(context);
// Get actual bytes read
if (context.Items["PayloadLimits:CountingStream"] is ByteCountingStream cs)
{
actualBytesRead = cs.BytesRead;
}
}
catch (PayloadLimitExceededException ex)
{
_logger.LogWarning(
"Payload limit exceeded mid-stream. ConnectionId: {ConnectionId}, BytesRead: {BytesRead}, Limit: {Limit}",
connectionId,
ex.BytesRead,
ex.Limit);
// Only set response if not already started
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
await context.Response.WriteAsJsonAsync(new
{
error = "Payload Too Large",
maxBytes = _limits.MaxRequestBytesPerCall,
bytesReceived = ex.BytesRead
});
}
actualBytesRead = ex.BytesRead;
}
finally
{
// Restore original body stream
context.Request.Body = originalBody;
// Release reserved capacity
var bytesToRelease = actualBytesRead > 0 ? actualBytesRead : contentLength;
tracker.Release(connectionId, bytesToRelease);
}
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Tracks payload bytes across requests, connections, and globally.
/// </summary>
public interface IPayloadTracker
{
/// <summary>
/// Tries to reserve capacity for an estimated payload size.
/// </summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="estimatedBytes">The estimated bytes to reserve.</param>
/// <returns>True if capacity was reserved; false if limits would be exceeded.</returns>
bool TryReserve(string connectionId, long estimatedBytes);
/// <summary>
/// Releases previously reserved capacity.
/// </summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="actualBytes">The actual bytes to release.</param>
void Release(string connectionId, long actualBytes);
/// <summary>
/// Gets the current total inflight bytes across all connections.
/// </summary>
long CurrentInflightBytes { get; }
/// <summary>
/// Gets a value indicating whether the system is overloaded.
/// </summary>
bool IsOverloaded { get; }
/// <summary>
/// Gets the current inflight bytes for a specific connection.
/// </summary>
/// <param name="connectionId">The connection identifier.</param>
/// <returns>The current inflight bytes for the connection.</returns>
long GetConnectionInflightBytes(string connectionId);
}
/// <summary>
/// Default implementation of <see cref="IPayloadTracker"/>.
/// </summary>
public sealed class PayloadTracker : IPayloadTracker
{
private readonly PayloadLimits _limits;
private readonly ILogger<PayloadTracker> _logger;
private long _totalInflightBytes;
private readonly ConcurrentDictionary<string, long> _perConnectionBytes = new();
/// <summary>
/// Initializes a new instance of the <see cref="PayloadTracker"/> class.
/// </summary>
public PayloadTracker(IOptions<PayloadLimits> limits, ILogger<PayloadTracker> logger)
{
_limits = limits.Value;
_logger = logger;
}
/// <inheritdoc />
public long CurrentInflightBytes => Interlocked.Read(ref _totalInflightBytes);
/// <inheritdoc />
public bool IsOverloaded => CurrentInflightBytes > _limits.MaxAggregateInflightBytes;
/// <inheritdoc />
public bool TryReserve(string connectionId, long estimatedBytes)
{
// Check aggregate limit
var newTotal = Interlocked.Add(ref _totalInflightBytes, estimatedBytes);
if (newTotal > _limits.MaxAggregateInflightBytes)
{
Interlocked.Add(ref _totalInflightBytes, -estimatedBytes);
_logger.LogWarning(
"Aggregate payload limit exceeded. Current: {Current}, Limit: {Limit}",
newTotal - estimatedBytes,
_limits.MaxAggregateInflightBytes);
return false;
}
// Check per-connection limit
var connectionBytes = _perConnectionBytes.AddOrUpdate(
connectionId,
estimatedBytes,
(_, current) => current + estimatedBytes);
if (connectionBytes > _limits.MaxRequestBytesPerConnection)
{
// Roll back
_perConnectionBytes.AddOrUpdate(
connectionId,
0,
(_, current) => current - estimatedBytes);
Interlocked.Add(ref _totalInflightBytes, -estimatedBytes);
_logger.LogWarning(
"Per-connection payload limit exceeded for {ConnectionId}. Current: {Current}, Limit: {Limit}",
connectionId,
connectionBytes - estimatedBytes,
_limits.MaxRequestBytesPerConnection);
return false;
}
return true;
}
/// <inheritdoc />
public void Release(string connectionId, long actualBytes)
{
Interlocked.Add(ref _totalInflightBytes, -actualBytes);
_perConnectionBytes.AddOrUpdate(
connectionId,
0,
(_, current) => Math.Max(0, current - actualBytes));
}
/// <inheritdoc />
public long GetConnectionInflightBytes(string connectionId)
{
return _perConnectionBytes.TryGetValue(connectionId, out var bytes) ? bytes : 0;
}
}

View File

@@ -0,0 +1,107 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Makes routing decisions for resolved endpoints.
/// </summary>
public sealed class RoutingDecisionMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Initializes a new instance of the <see cref="RoutingDecisionMiddleware"/> class.
/// </summary>
public RoutingDecisionMiddleware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task Invoke(
HttpContext context,
IRoutingPlugin routingPlugin,
IGlobalRoutingState routingState,
IOptions<GatewayNodeConfig> gatewayConfig,
IOptions<RoutingOptions> routingOptions)
{
var endpoint = context.Items[RouterHttpContextKeys.EndpointDescriptor] as EndpointDescriptor;
if (endpoint is null)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Endpoint descriptor missing" });
return;
}
// Build routing context
var availableConnections = routingState.GetConnectionsFor(
endpoint.ServiceName,
endpoint.Version,
endpoint.Method,
endpoint.Path);
var headers = context.Request.Headers
.ToDictionary(h => h.Key, h => h.Value.ToString());
var routingContext = new RoutingContext
{
Method = context.Request.Method,
Path = context.Request.Path.ToString(),
Headers = headers,
Endpoint = endpoint,
AvailableConnections = availableConnections,
GatewayRegion = gatewayConfig.Value.Region,
RequestedVersion = ExtractVersionFromRequest(context, routingOptions.Value),
CancellationToken = context.RequestAborted
};
var decision = await routingPlugin.ChooseInstanceAsync(
routingContext,
context.RequestAborted);
if (decision is null)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
error = "No instances available",
service = endpoint.ServiceName,
version = endpoint.Version
});
return;
}
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
await _next(context);
}
private static string? ExtractVersionFromRequest(HttpContext context, RoutingOptions options)
{
// Check for version in Accept header: Accept: application/vnd.stellaops.v1+json
var acceptHeader = context.Request.Headers.Accept.FirstOrDefault();
if (!string.IsNullOrEmpty(acceptHeader))
{
var versionMatch = System.Text.RegularExpressions.Regex.Match(
acceptHeader,
@"application/vnd\.stellaops\.v(\d+(?:\.\d+)*)\+json");
if (versionMatch.Success)
{
return versionMatch.Groups[1].Value;
}
}
// Check for X-Api-Version header
var versionHeader = context.Request.Headers["X-Api-Version"].FirstOrDefault();
if (!string.IsNullOrEmpty(versionHeader))
{
return versionHeader;
}
// Fall back to default version from options
return options.DefaultVersion;
}
}

View File

@@ -0,0 +1,457 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Dispatches HTTP requests to microservices via the transport layer.
/// </summary>
public sealed class TransportDispatchMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TransportDispatchMiddleware> _logger;
/// <summary>
/// Tracks cancelled request IDs to ignore late responses.
/// Keys expire after 60 seconds to prevent memory leaks.
/// </summary>
private static readonly ConcurrentDictionary<string, DateTimeOffset> CancelledRequests = new();
/// <summary>
/// Initializes a new instance of the <see cref="TransportDispatchMiddleware"/> class.
/// </summary>
public TransportDispatchMiddleware(RequestDelegate next, ILogger<TransportDispatchMiddleware> logger)
{
_next = next;
_logger = logger;
// Start background cleanup task for expired cancelled request entries
_ = Task.Run(CleanupExpiredCancelledRequestsAsync);
}
private static async Task CleanupExpiredCancelledRequestsAsync()
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(30));
var cutoff = DateTimeOffset.UtcNow.AddSeconds(-60);
foreach (var kvp in CancelledRequests)
{
if (kvp.Value < cutoff)
{
CancelledRequests.TryRemove(kvp.Key, out _);
}
}
}
}
private static void MarkCancelled(string requestId)
{
CancelledRequests[requestId] = DateTimeOffset.UtcNow;
}
private static bool IsCancelled(string requestId)
{
return CancelledRequests.ContainsKey(requestId);
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task Invoke(
HttpContext context,
ITransportClient transportClient,
IGlobalRoutingState routingState)
{
var decision = context.Items[RouterHttpContextKeys.RoutingDecision] as RoutingDecision;
if (decision is null)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Routing decision missing" });
return;
}
var requestId = Guid.NewGuid().ToString("N");
// Extract headers (exclude some internal headers)
var headers = context.Request.Headers
.Where(h => !h.Key.StartsWith(":", StringComparison.Ordinal))
.ToDictionary(
h => h.Key,
h => h.Value.ToString());
// For streaming endpoints, use streaming dispatch
if (decision.Endpoint.SupportsStreaming)
{
await DispatchStreamingAsync(context, transportClient, routingState, decision, requestId, headers);
return;
}
// Read request body (buffered)
byte[] bodyBytes;
using (var ms = new MemoryStream())
{
await context.Request.Body.CopyToAsync(ms, context.RequestAborted);
bodyBytes = ms.ToArray();
}
// Build request frame
var requestFrame = new RequestFrame
{
RequestId = requestId,
CorrelationId = context.TraceIdentifier,
Method = context.Request.Method,
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
Headers = headers,
Payload = bodyBytes,
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
SupportsStreaming = false
};
var frame = FrameConverter.ToFrame(requestFrame);
_logger.LogDebug(
"Dispatching {Method} {Path} to {ServiceName}/{Version} via {TransportType}",
requestFrame.Method,
requestFrame.Path,
decision.Connection.Instance.ServiceName,
decision.Connection.Instance.Version,
decision.TransportType);
// Create linked cancellation token with timeout
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
timeoutCts.CancelAfter(decision.EffectiveTimeout);
// Register client disconnect handler to send CANCEL
var requestIdGuid = Guid.TryParse(requestId, out var parsed) ? parsed : Guid.NewGuid();
using var clientDisconnectRegistration = context.RequestAborted.Register(() =>
{
// Mark as cancelled to ignore late responses
MarkCancelled(requestId);
// Send CANCEL frame (fire and forget)
_ = Task.Run(async () =>
{
try
{
await transportClient.SendCancelAsync(
decision.Connection,
requestIdGuid,
CancelReasons.ClientDisconnected);
_logger.LogDebug(
"Sent CANCEL for request {RequestId} due to client disconnect",
requestId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send CANCEL for request {RequestId} on client disconnect",
requestId);
}
});
});
Frame responseFrame;
var startTimestamp = Stopwatch.GetTimestamp();
try
{
responseFrame = await transportClient.SendRequestAsync(
decision.Connection,
frame,
decision.EffectiveTimeout,
timeoutCts.Token);
// Record ping latency and update connection's average
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
UpdateConnectionPing(routingState, decision.Connection.ConnectionId, elapsed.TotalMilliseconds);
}
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
{
// Internal timeout (not client disconnect)
_logger.LogWarning(
"Request {RequestId} to {ServiceName} timed out after {Timeout}",
requestId,
decision.Connection.Instance.ServiceName,
decision.EffectiveTimeout);
// Mark as cancelled to ignore late responses
MarkCancelled(requestId);
// Send cancel to microservice
try
{
await transportClient.SendCancelAsync(
decision.Connection,
requestIdGuid,
CancelReasons.Timeout);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send cancel for request {RequestId}", requestId);
}
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
await context.Response.WriteAsJsonAsync(new
{
error = "Upstream timeout",
service = decision.Connection.Instance.ServiceName,
timeout = decision.EffectiveTimeout.TotalSeconds
});
return;
}
catch (OperationCanceledException)
{
// Client disconnected - cancel already sent via registration above
MarkCancelled(requestId);
_logger.LogDebug("Client disconnected, request {RequestId} cancelled", requestId);
return;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error dispatching request {RequestId} to {ServiceName}",
requestId,
decision.Connection.Instance.ServiceName);
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsJsonAsync(new
{
error = "Upstream error",
message = ex.Message
});
return;
}
// Check if request was cancelled while waiting for response
if (IsCancelled(requestId))
{
_logger.LogDebug("Ignoring late response for cancelled request {RequestId}", requestId);
return;
}
// Parse response
var response = FrameConverter.ToResponseFrame(responseFrame);
if (response is null)
{
_logger.LogError(
"Invalid response frame from {ServiceName} for request {RequestId}",
decision.Connection.Instance.ServiceName,
requestId);
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsJsonAsync(new { error = "Invalid upstream response" });
return;
}
// Map response to HTTP
context.Response.StatusCode = response.StatusCode;
// Copy response headers
foreach (var (key, value) in response.Headers)
{
// Skip some headers that shouldn't be copied
if (key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
{
continue;
}
context.Response.Headers[key] = value;
}
// Write response body
if (response.Payload.Length > 0)
{
await context.Response.Body.WriteAsync(response.Payload, context.RequestAborted);
}
_logger.LogDebug(
"Request {RequestId} completed with status {StatusCode}",
requestId,
response.StatusCode);
}
/// <summary>
/// Updates the connection's average ping time using exponential moving average.
/// </summary>
private static void UpdateConnectionPing(
IGlobalRoutingState routingState,
string connectionId,
double pingMs)
{
const double smoothingFactor = 0.2;
routingState.UpdateConnection(connectionId, connection =>
{
if (connection.AveragePingMs == 0)
{
connection.AveragePingMs = pingMs;
}
else
{
connection.AveragePingMs = (1 - smoothingFactor) * connection.AveragePingMs + smoothingFactor * pingMs;
}
});
}
/// <summary>
/// Dispatches a streaming request to a microservice.
/// </summary>
private async Task DispatchStreamingAsync(
HttpContext context,
ITransportClient transportClient,
IGlobalRoutingState routingState,
RoutingDecision decision,
string requestId,
Dictionary<string, string> headers)
{
var requestIdGuid = Guid.TryParse(requestId, out var parsed) ? parsed : Guid.NewGuid();
// Build request header frame (without body - will stream)
var requestFrame = new RequestFrame
{
RequestId = requestId,
CorrelationId = context.TraceIdentifier,
Method = context.Request.Method,
Path = context.Request.Path.ToString() + context.Request.QueryString.ToString(),
Headers = headers,
Payload = Array.Empty<byte>(), // Empty - body will be streamed
TimeoutSeconds = (int)decision.EffectiveTimeout.TotalSeconds,
SupportsStreaming = true
};
var frame = FrameConverter.ToFrame(requestFrame);
_logger.LogDebug(
"Dispatching streaming {Method} {Path} to {ServiceName}/{Version}",
requestFrame.Method,
requestFrame.Path,
decision.Connection.Instance.ServiceName,
decision.Connection.Instance.Version);
// Create linked cancellation token with timeout
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
timeoutCts.CancelAfter(decision.EffectiveTimeout);
// Register client disconnect handler to send CANCEL
using var clientDisconnectRegistration = context.RequestAborted.Register(() =>
{
MarkCancelled(requestId);
_ = Task.Run(async () =>
{
try
{
await transportClient.SendCancelAsync(
decision.Connection,
requestIdGuid,
CancelReasons.ClientDisconnected);
_logger.LogDebug(
"Sent CANCEL for streaming request {RequestId} due to client disconnect",
requestId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send CANCEL for streaming request {RequestId}",
requestId);
}
});
});
var startTimestamp = Stopwatch.GetTimestamp();
var responseReceived = false;
try
{
// Use streaming transport method
await transportClient.SendStreamingAsync(
decision.Connection,
frame,
context.Request.Body,
async responseBodyStream =>
{
responseReceived = true;
// For now, read the response stream and write to HTTP response
// The response headers should be set before streaming begins
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.Headers["Transfer-Encoding"] = "chunked";
context.Response.ContentType = "application/octet-stream";
await responseBodyStream.CopyToAsync(context.Response.Body, timeoutCts.Token);
},
PayloadLimits.Default,
timeoutCts.Token);
// Record ping latency
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
UpdateConnectionPing(routingState, decision.Connection.ConnectionId, elapsed.TotalMilliseconds);
_logger.LogDebug(
"Streaming request {RequestId} completed",
requestId);
}
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
{
// Internal timeout
_logger.LogWarning(
"Streaming request {RequestId} timed out after {Timeout}",
requestId,
decision.EffectiveTimeout);
MarkCancelled(requestId);
try
{
await transportClient.SendCancelAsync(
decision.Connection,
requestIdGuid,
CancelReasons.Timeout);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send cancel for streaming request {RequestId}", requestId);
}
if (!responseReceived)
{
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
await context.Response.WriteAsJsonAsync(new
{
error = "Upstream streaming timeout",
service = decision.Connection.Instance.ServiceName,
timeout = decision.EffectiveTimeout.TotalSeconds
});
}
}
catch (OperationCanceledException)
{
// Client disconnected
MarkCancelled(requestId);
_logger.LogDebug("Client disconnected, streaming request {RequestId} cancelled", requestId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error dispatching streaming request {RequestId}",
requestId);
if (!responseReceived)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsJsonAsync(new
{
error = "Upstream streaming error",
message = ex.Message
});
}
}
}
}

View File

@@ -0,0 +1,84 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Tracks round-trip time for requests to compute average ping latency.
/// </summary>
internal sealed class PingTracker
{
private readonly ConcurrentDictionary<Guid, long> _pendingRequests = new();
private readonly object _lock = new();
private double _averagePingMs;
private const double SmoothingFactor = 0.2;
/// <summary>
/// Gets the exponential moving average of ping times in milliseconds.
/// </summary>
public double AveragePingMs
{
get
{
lock (_lock)
{
return _averagePingMs;
}
}
}
/// <summary>
/// Records that a request has been sent.
/// </summary>
/// <param name="correlationId">The correlation ID of the request.</param>
public void RecordRequestSent(Guid correlationId)
{
_pendingRequests[correlationId] = Stopwatch.GetTimestamp();
}
/// <summary>
/// Records that a response has been received and updates the average ping.
/// </summary>
/// <param name="correlationId">The correlation ID of the request.</param>
/// <returns>The round-trip time in milliseconds, or null if the correlation ID was not found.</returns>
public double? RecordResponseReceived(Guid correlationId)
{
if (!_pendingRequests.TryRemove(correlationId, out var startTicks))
{
return null;
}
var elapsed = Stopwatch.GetElapsedTime(startTicks);
var rtt = elapsed.TotalMilliseconds;
lock (_lock)
{
// Exponential moving average: avg = (1 - alpha) * avg + alpha * new_value
if (_averagePingMs == 0)
{
_averagePingMs = rtt; // First measurement
}
else
{
_averagePingMs = (1 - SmoothingFactor) * _averagePingMs + SmoothingFactor * rtt;
}
}
return rtt;
}
/// <summary>
/// Removes a pending request without recording a response.
/// Call this when a request times out or is cancelled.
/// </summary>
/// <param name="correlationId">The correlation ID of the request.</param>
public void RemovePending(Guid correlationId)
{
_pendingRequests.TryRemove(correlationId, out _);
}
/// <summary>
/// Gets the number of pending requests.
/// </summary>
public int PendingCount => _pendingRequests.Count;
}

View File

@@ -1,12 +1,19 @@
using StellaOps.Gateway.WebService;
var builder = WebApplication.CreateBuilder(args);
// Placeholder: Gateway services will be registered here in later sprints
// Register gateway routing services
builder.Services.AddGatewayRouting(builder.Configuration);
var app = builder.Build();
// Placeholder: Middleware pipeline will be configured here in later sprints
// Health check endpoint (not routed through gateway middleware)
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
// Gateway router middleware pipeline
// All other requests are routed through the gateway
app.UseGatewayRouter();
app.Run();
// Make Program class accessible for integration tests

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Well-known HttpContext.Items keys for router pipeline.
/// </summary>
public static class RouterHttpContextKeys
{
/// <summary>
/// Key for the resolved <see cref="StellaOps.Router.Common.Models.EndpointDescriptor"/>.
/// </summary>
public const string EndpointDescriptor = "Stella.EndpointDescriptor";
/// <summary>
/// Key for the <see cref="StellaOps.Router.Common.Models.RoutingDecision"/>.
/// </summary>
public const string RoutingDecision = "Stella.RoutingDecision";
/// <summary>
/// Key for path parameters extracted from route template matching.
/// </summary>
public const string PathParameters = "Stella.PathParameters";
}

View File

@@ -0,0 +1,67 @@
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Tie-breaker mode for routing when multiple instances have equal priority.
/// </summary>
public enum TieBreakerMode
{
/// <summary>
/// Select randomly among tied instances.
/// </summary>
Random,
/// <summary>
/// Rotate through tied instances in order.
/// </summary>
RoundRobin
}
/// <summary>
/// Options for routing behavior.
/// </summary>
public sealed class RoutingOptions
{
/// <summary>
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "Routing";
/// <summary>
/// Gets or sets the default version to use when no version is specified in the request.
/// If null, requests without version specification will match any available version.
/// </summary>
public string? DefaultVersion { get; set; }
/// <summary>
/// Gets or sets whether to enable strict version matching.
/// When true, requests must specify an exact version.
/// When false, requests can match compatible versions.
/// </summary>
public bool StrictVersionMatching { get; set; } = true;
/// <summary>
/// Gets or sets the timeout for routing decisions in milliseconds.
/// </summary>
public int RoutingTimeoutMs { get; set; } = 30000;
/// <summary>
/// Gets or sets whether to prefer local region instances over neighbor regions.
/// </summary>
public bool PreferLocalRegion { get; set; } = true;
/// <summary>
/// Gets or sets whether to allow routing to degraded instances when no healthy instances are available.
/// </summary>
public bool AllowDegradedInstances { get; set; } = true;
/// <summary>
/// Gets or sets the tie-breaker mode when multiple instances have equal priority.
/// </summary>
public TieBreakerMode TieBreaker { get; set; } = TieBreakerMode.Random;
/// <summary>
/// Gets or sets the ping tolerance in milliseconds for considering instances "tied".
/// Instances within this tolerance of each other are considered to have equal latency.
/// </summary>
public double PingToleranceMs { get; set; } = 0.1;
}

View File

@@ -0,0 +1,82 @@
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Transport.InMemory;
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Extension methods for registering gateway routing services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds gateway routing services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayRouting(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration options
services.Configure<GatewayNodeConfig>(
configuration.GetSection(GatewayNodeConfig.SectionName));
services.Configure<RoutingOptions>(
configuration.GetSection(RoutingOptions.SectionName));
services.Configure<HealthOptions>(
configuration.GetSection(HealthOptions.SectionName));
// Register routing state as singleton (shared across all requests)
services.AddSingleton<IGlobalRoutingState, InMemoryRoutingState>();
// Register routing plugin
services.AddSingleton<IRoutingPlugin, DefaultRoutingPlugin>();
// Register InMemory transport (for development/testing)
services.AddInMemoryTransport();
// Register connection manager as hosted service
services.AddHostedService<ConnectionManager>();
// Register health monitor as hosted service
services.AddHostedService<HealthMonitorService>();
return services;
}
/// <summary>
/// Adds gateway routing services with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureGateway">Action to configure gateway node options.</param>
/// <param name="configureRouting">Action to configure routing options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayRouting(
this IServiceCollection services,
Action<GatewayNodeConfig>? configureGateway = null,
Action<RoutingOptions>? configureRouting = null)
{
// Ensure default options are registered even if no configuration action provided
services.AddOptions<GatewayNodeConfig>();
services.AddOptions<RoutingOptions>();
// Configure options via actions
if (configureGateway is not null)
{
services.Configure(configureGateway);
}
if (configureRouting is not null)
{
services.Configure(configureRouting);
}
// Register routing state as singleton (shared across all requests)
services.AddSingleton<IGlobalRoutingState, InMemoryRoutingState>();
// Register routing plugin
services.AddSingleton<IRoutingPlugin, DefaultRoutingPlugin>();
return services;
}
}

View File

@@ -9,5 +9,9 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,222 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Microservice;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class CancellationTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public void CancelReasons_HasAllExpectedConstants()
{
Assert.Equal("ClientDisconnected", CancelReasons.ClientDisconnected);
Assert.Equal("Timeout", CancelReasons.Timeout);
Assert.Equal("PayloadLimitExceeded", CancelReasons.PayloadLimitExceeded);
Assert.Equal("Shutdown", CancelReasons.Shutdown);
Assert.Equal("ConnectionClosed", CancelReasons.ConnectionClosed);
}
[Fact]
public async Task ConnectAsync_RegistersWithRegistry()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
// Act
await client.ConnectAsync(instance, [], CancellationToken.None);
// Assert
var connectionIdField = client.GetType()
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var connectionId = connectionIdField?.GetValue(client)?.ToString();
Assert.NotNull(connectionId);
var channel = _registry.GetChannel(connectionId!);
Assert.NotNull(channel);
Assert.Equal(instance.InstanceId, channel!.Instance?.InstanceId);
}
[Fact]
public void CancelAllInflight_DoesNotThrowWhenEmpty()
{
// Arrange
using var client = CreateClient();
// Act & Assert - should not throw
client.CancelAllInflight(CancelReasons.Shutdown);
}
[Fact]
public void Dispose_DoesNotThrow()
{
// Arrange
var client = CreateClient();
// Act & Assert - should not throw
client.Dispose();
}
[Fact]
public async Task DisconnectAsync_CancelsAllInflightWithShutdownReason()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
await client.ConnectAsync(instance, [], CancellationToken.None);
// Act
await client.DisconnectAsync();
// Assert - no exception means success
}
}
public class InflightRequestTrackerTests
{
[Fact]
public void Track_ReturnsCancellationToken()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
// Act
var token = tracker.Track(correlationId);
// Assert
Assert.False(token.IsCancellationRequested);
Assert.Equal(1, tracker.Count);
}
[Fact]
public void Track_ThrowsIfAlreadyTracked()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
tracker.Track(correlationId);
// Act & Assert
Assert.Throws<InvalidOperationException>(() => tracker.Track(correlationId));
}
[Fact]
public void Cancel_TriggersCancellationToken()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
var token = tracker.Track(correlationId);
// Act
var result = tracker.Cancel(correlationId, "TestReason");
// Assert
Assert.True(result);
Assert.True(token.IsCancellationRequested);
}
[Fact]
public void Cancel_ReturnsFalseForUnknownRequest()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
// Act
var result = tracker.Cancel(correlationId, "TestReason");
// Assert
Assert.False(result);
}
[Fact]
public void Complete_RemovesFromTracking()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var correlationId = Guid.NewGuid();
tracker.Track(correlationId);
Assert.Equal(1, tracker.Count);
// Act
tracker.Complete(correlationId);
// Assert
Assert.Equal(0, tracker.Count);
}
[Fact]
public void CancelAll_CancelsAllTrackedRequests()
{
// Arrange
using var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var tokens = new List<CancellationToken>();
for (var i = 0; i < 5; i++)
{
tokens.Add(tracker.Track(Guid.NewGuid()));
}
// Act
tracker.CancelAll("TestReason");
// Assert
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
}
[Fact]
public void Dispose_CancelsAllTrackedRequests()
{
// Arrange
var tracker = new InflightRequestTracker(
NullLogger<InflightRequestTracker>.Instance);
var tokens = new List<CancellationToken>();
for (var i = 0; i < 3; i++)
{
tokens.Add(tracker.Track(Guid.NewGuid()));
}
// Act
tracker.Dispose();
// Assert
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
}
}

View File

@@ -0,0 +1,213 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Integration-style tests for <see cref="ConnectionManager"/>.
/// Uses real InMemoryTransportServer since it's a sealed class.
/// </summary>
public sealed class ConnectionManagerTests : IAsyncLifetime
{
private readonly InMemoryConnectionRegistry _connectionRegistry;
private readonly InMemoryTransportServer _transportServer;
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly ConnectionManager _manager;
public ConnectionManagerTests()
{
_connectionRegistry = new InMemoryConnectionRegistry();
var options = Options.Create(new InMemoryTransportOptions());
_transportServer = new InMemoryTransportServer(
_connectionRegistry,
options,
NullLogger<InMemoryTransportServer>.Instance);
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
_manager = new ConnectionManager(
_transportServer,
_connectionRegistry,
_routingStateMock.Object,
NullLogger<ConnectionManager>.Instance);
}
public async Task InitializeAsync()
{
await _manager.StartAsync(CancellationToken.None);
}
public async Task DisposeAsync()
{
await _manager.StopAsync(CancellationToken.None);
_transportServer.Dispose();
}
#region StartAsync/StopAsync Tests
[Fact]
public async Task StartAsync_ShouldStartSuccessfully()
{
// The manager starts in InitializeAsync
// Just verify it can be started without exception
await Task.CompletedTask;
}
[Fact]
public async Task StopAsync_ShouldStopSuccessfully()
{
// This is tested in DisposeAsync
await Task.CompletedTask;
}
#endregion
#region Connection Registration Tests via Channel Simulation
[Fact]
public async Task WhenHelloReceived_AddsConnectionToRoutingState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// Simulate sending a HELLO frame through the channel
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
// Act
await channel.ToGateway.Writer.WriteAsync(helloFrame);
// Give time for the frame to be processed
await Task.Delay(100);
// Assert
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
Times.Once);
}
[Fact]
public async Task WhenHeartbeatReceived_UpdatesConnectionState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// First send HELLO to register the connection
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(helloFrame);
await Task.Delay(100);
// Act - send heartbeat
var heartbeatFrame = new Frame
{
Type = FrameType.Heartbeat,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(heartbeatFrame);
await Task.Delay(100);
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task WhenConnectionClosed_RemovesConnectionFromRoutingState()
{
// Arrange
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
// First send HELLO to register the connection
var helloFrame = new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
};
await channel.ToGateway.Writer.WriteAsync(helloFrame);
await Task.Delay(100);
// Act - close the channel
await channel.LifetimeToken.CancelAsync();
// Give time for the close to be processed
await Task.Delay(200);
// Assert - may be called multiple times (on close and on stop)
_routingStateMock.Verify(
s => s.RemoveConnection("conn-1"),
Times.AtLeastOnce);
}
[Fact]
public async Task WhenMultipleConnectionsRegister_AllAreTracked()
{
// Arrange
var channel1 = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
var channel2 = CreateAndRegisterChannel("conn-2", "service-b", "2.0.0");
// Act - send HELLO frames
await channel1.ToGateway.Writer.WriteAsync(new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
});
await channel2.ToGateway.Writer.WriteAsync(new Frame
{
Type = FrameType.Hello,
CorrelationId = Guid.NewGuid().ToString()
});
await Task.Delay(150);
// Assert
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
Times.Once);
_routingStateMock.Verify(
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-2")),
Times.Once);
}
#endregion
#region Helper Methods
private InMemoryChannel CreateAndRegisterChannel(
string connectionId, string serviceName, string version)
{
var instance = new InstanceDescriptor
{
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
ServiceName = serviceName,
Version = version,
Region = "us-east-1"
};
// Create channel through the registry
var channel = _connectionRegistry.CreateChannel(connectionId);
channel.Instance = instance;
// Simulate that the transport server is listening to this connection
_transportServer.StartListeningToConnection(connectionId);
return channel;
}
#endregion
}

View File

@@ -0,0 +1,538 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class DefaultRoutingPluginTests
{
private readonly RoutingOptions _options = new()
{
DefaultVersion = null,
StrictVersionMatching = true,
RoutingTimeoutMs = 30000,
PreferLocalRegion = true,
AllowDegradedInstances = true,
TieBreaker = TieBreakerMode.Random,
PingToleranceMs = 0.1
};
private readonly GatewayNodeConfig _gatewayConfig = new()
{
Region = "us-east-1",
NodeId = "gw-test-01",
Environment = "test",
NeighborRegions = ["eu-west-1", "us-west-2"]
};
private DefaultRoutingPlugin CreateSut(
Action<RoutingOptions>? configureOptions = null,
Action<GatewayNodeConfig>? configureGateway = null)
{
configureOptions?.Invoke(_options);
configureGateway?.Invoke(_gatewayConfig);
return new DefaultRoutingPlugin(
Options.Create(_options),
Options.Create(_gatewayConfig));
}
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
string serviceName = "test-service",
string version = "1.0.0",
string region = "us-east-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
double averagePingMs = 0,
DateTime? lastHeartbeatUtc = null)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
TransportType = TransportType.InMemory,
AveragePingMs = averagePingMs,
LastHeartbeatUtc = lastHeartbeatUtc ?? DateTime.UtcNow
};
}
private static EndpointDescriptor CreateEndpoint(
string method = "GET",
string path = "/api/test",
string serviceName = "test-service",
string version = "1.0.0")
{
return new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version
};
}
private static RoutingContext CreateContext(
string method = "GET",
string path = "/api/test",
string gatewayRegion = "us-east-1",
string? requestedVersion = null,
EndpointDescriptor? endpoint = null,
params ConnectionState[] connections)
{
return new RoutingContext
{
Method = method,
Path = path,
GatewayRegion = gatewayRegion,
RequestedVersion = requestedVersion,
Endpoint = endpoint ?? CreateEndpoint(),
AvailableConnections = connections
};
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoConnections()
{
// Arrange
var sut = CreateSut();
var context = CreateContext();
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoEndpoint()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection();
var context = new RoutingContext
{
Method = "GET",
Path = "/api/test",
GatewayRegion = "us-east-1",
Endpoint = null,
AvailableConnections = [connection]
};
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSelectHealthyConnection()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection(status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Should().BeSameAs(connection);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferHealthyOverDegraded()
{
// Arrange
var sut = CreateSut();
var degraded = CreateConnection("conn-1", status: InstanceHealthStatus.Degraded);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [degraded, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSelectDegraded_WhenNoHealthyAndAllowed()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = true);
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
var context = CreateContext(connections: [degraded]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenOnlyDegradedAndNotAllowed()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = false);
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
var context = CreateContext(connections: [degraded]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldExcludeUnhealthy()
{
// Arrange
var sut = CreateSut();
var unhealthy = CreateConnection("conn-1", status: InstanceHealthStatus.Unhealthy);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [unhealthy, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldExcludeDraining()
{
// Arrange
var sut = CreateSut();
var draining = CreateConnection("conn-1", status: InstanceHealthStatus.Draining);
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
var context = CreateContext(connections: [draining, healthy]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldFilterByRequestedVersion()
{
// Arrange
var sut = CreateSut();
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Version.Should().Be("2.0.0");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldUseDefaultVersion_WhenNoRequestedVersion()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.DefaultVersion = "1.0.0");
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Version.Should().Be("1.0.0");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoMatchingVersion()
{
// Arrange
var sut = CreateSut();
var v1 = CreateConnection("conn-1", version: "1.0.0");
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldMatchAnyVersion_WhenNoVersionSpecified()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.DefaultVersion = null);
var v1 = CreateConnection("conn-1", version: "1.0.0");
var v2 = CreateConnection("conn-2", version: "2.0.0");
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferLocalRegion()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
var remote = CreateConnection("conn-1", region: "us-west-2");
var local = CreateConnection("conn-2", region: "us-east-1");
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("us-east-1");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldAllowRemoteRegion_WhenNoLocalAvailable()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
var remote = CreateConnection("conn-1", region: "us-west-2");
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("us-west-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldIgnoreRegionPreference_WhenDisabled()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = false);
// Create connections with same ping and heartbeat so they are tied
var sameHeartbeat = DateTime.UtcNow;
var remote = CreateConnection("conn-1", region: "us-west-2", lastHeartbeatUtc: sameHeartbeat);
var local = CreateConnection("conn-2", region: "us-east-1", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
// Act - run multiple times to verify random selection includes both
var selectedRegions = new HashSet<string>();
for (int i = 0; i < 50; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
selectedRegions.Add(result!.Connection.Instance.Region);
}
// Assert - with random selection, we should see both regions selected
// Note: This is probabilistic but should almost always pass
selectedRegions.Should().Contain("us-west-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSetCorrectTimeout()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.RoutingTimeoutMs = 5000);
var connection = CreateConnection();
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.EffectiveTimeout.Should().Be(TimeSpan.FromMilliseconds(5000));
}
[Fact]
public async Task ChooseInstanceAsync_ShouldSetCorrectTransportType()
{
// Arrange
var sut = CreateSut();
var connection = CreateConnection();
var context = CreateContext(connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.TransportType.Should().Be(TransportType.InMemory);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldReturnEndpointFromContext()
{
// Arrange
var sut = CreateSut();
var endpoint = CreateEndpoint(path: "/api/special");
var connection = CreateConnection();
var context = CreateContext(endpoint: endpoint, connections: [connection]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Endpoint.Path.Should().Be("/api/special");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldDistributeLoadAcrossMultipleConnections()
{
// Arrange
var sut = CreateSut();
// Create connections with same ping and heartbeat so they are tied
var sameHeartbeat = DateTime.UtcNow;
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
var conn3 = CreateConnection("conn-3", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [conn1, conn2, conn3]);
// Act - run multiple times
var selectedConnections = new Dictionary<string, int>();
for (int i = 0; i < 100; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
var connId = result!.Connection.ConnectionId;
selectedConnections[connId] = selectedConnections.GetValueOrDefault(connId) + 1;
}
// Assert - all connections should be selected at least once (probabilistic with random tie-breaker)
selectedConnections.Should().HaveCount(3);
selectedConnections.Keys.Should().Contain("conn-1");
selectedConnections.Keys.Should().Contain("conn-2");
selectedConnections.Keys.Should().Contain("conn-3");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferLowerPing()
{
// Arrange
var sut = CreateSut();
var sameHeartbeat = DateTime.UtcNow;
var highPing = CreateConnection("conn-1", averagePingMs: 100, lastHeartbeatUtc: sameHeartbeat);
var lowPing = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [highPing, lowPing]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - lower ping should be preferred
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferMoreRecentHeartbeat_WhenPingEqual()
{
// Arrange
var sut = CreateSut();
var now = DateTime.UtcNow;
var oldHeartbeat = CreateConnection("conn-1", averagePingMs: 10, lastHeartbeatUtc: now.AddSeconds(-30));
var recentHeartbeat = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: now);
var context = CreateContext(connections: [oldHeartbeat, recentHeartbeat]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - more recent heartbeat should be preferred
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-2");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldPreferNeighborRegionOverRemote()
{
// Arrange - gateway config has NeighborRegions = ["eu-west-1", "us-west-2"]
var sut = CreateSut();
var sameHeartbeat = DateTime.UtcNow;
var remoteRegion = CreateConnection("conn-1", region: "ap-south-1", lastHeartbeatUtc: sameHeartbeat);
var neighborRegion = CreateConnection("conn-2", region: "eu-west-1", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remoteRegion, neighborRegion]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - neighbor region should be preferred over remote
result.Should().NotBeNull();
result!.Connection.Instance.Region.Should().Be("eu-west-1");
}
[Fact]
public async Task ChooseInstanceAsync_ShouldUseRoundRobin_WhenConfigured()
{
// Arrange
var sut = CreateSut(configureOptions: o => o.TieBreaker = TieBreakerMode.RoundRobin);
var sameHeartbeat = DateTime.UtcNow;
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
var context = CreateContext(connections: [conn1, conn2]);
// Act - with round-robin, we should cycle through connections
var selections = new List<string>();
for (int i = 0; i < 4; i++)
{
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
selections.Add(result!.Connection.ConnectionId);
}
// Assert - should alternate between connections
selections.Distinct().Count().Should().Be(2);
}
[Fact]
public async Task ChooseInstanceAsync_ShouldCombineFilters()
{
// Arrange
var sut = CreateSut(configureOptions: o =>
{
o.PreferLocalRegion = true;
o.AllowDegradedInstances = false;
});
// Create various combinations
var wrongVersionHealthyLocal = CreateConnection("conn-1", version: "2.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
var rightVersionDegradedLocal = CreateConnection("conn-2", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Degraded);
var rightVersionHealthyRemote = CreateConnection("conn-3", version: "1.0.0", region: "us-west-2", status: InstanceHealthStatus.Healthy);
var rightVersionHealthyLocal = CreateConnection("conn-4", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
var context = CreateContext(
gatewayRegion: "us-east-1",
requestedVersion: "1.0.0",
connections: [wrongVersionHealthyLocal, rightVersionDegradedLocal, rightVersionHealthyRemote, rightVersionHealthyLocal]);
// Act
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
// Assert - should select the only connection matching all criteria
result.Should().NotBeNull();
result!.Connection.ConnectionId.Should().Be("conn-4");
}
}

View File

@@ -0,0 +1,277 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Tests for <see cref="HealthMonitorService"/>.
/// </summary>
public sealed class HealthMonitorServiceTests
{
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly HealthOptions _options;
public HealthMonitorServiceTests()
{
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
_options = new HealthOptions
{
StaleThreshold = TimeSpan.FromSeconds(10),
DegradedThreshold = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromMilliseconds(100)
};
}
private HealthMonitorService CreateService()
{
return new HealthMonitorService(
_routingStateMock.Object,
Options.Create(_options),
NullLogger<HealthMonitorService>.Instance);
}
[Fact]
public async Task ExecuteAsync_MarksStaleConnectionsUnhealthy()
{
// Arrange
var staleConnection = CreateConnection("conn-1", "service-a", "1.0.0");
staleConnection.Status = InstanceHealthStatus.Healthy;
staleConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15); // Past stale threshold
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([staleConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_MarksDegradedConnectionsDegraded()
{
// Arrange
var degradedConnection = CreateConnection("conn-1", "service-a", "1.0.0");
degradedConnection.Status = InstanceHealthStatus.Healthy;
degradedConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-7); // Past degraded but not stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([degradedConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
// Act
try
{
await service.StartAsync(cts.Token);
// Wait enough time for at least one check cycle (CheckInterval is 100ms)
await Task.Delay(300, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
_routingStateMock.Verify(
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_DoesNotChangeHealthyConnections()
{
// Arrange
var healthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
healthyConnection.Status = InstanceHealthStatus.Healthy;
healthyConnection.LastHeartbeatUtc = DateTime.UtcNow; // Fresh heartbeat
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([healthyConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - should not have updated the connection
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_DoesNotChangeDrainingConnections()
{
// Arrange
var drainingConnection = CreateConnection("conn-1", "service-a", "1.0.0");
drainingConnection.Status = InstanceHealthStatus.Draining;
drainingConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([drainingConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - draining connections should be left alone
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_DoesNotDoubleMarkUnhealthy()
{
// Arrange
var unhealthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
unhealthyConnection.Status = InstanceHealthStatus.Unhealthy;
unhealthyConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([unhealthyConnection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert - already unhealthy connections should not be updated
_routingStateMock.Verify(
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
Times.Never);
}
[Fact]
public async Task UpdateAction_SetsStatusToUnhealthy()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", "1.0.0");
connection.Status = InstanceHealthStatus.Healthy;
connection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15);
Action<ConnectionState>? capturedAction = null;
_routingStateMock.Setup(s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()))
.Callback<string, Action<ConnectionState>>((id, action) => capturedAction = action);
_routingStateMock.Setup(s => s.GetAllConnections())
.Returns([connection]);
var service = CreateService();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act - run the service briefly
try
{
await service.StartAsync(cts.Token);
await Task.Delay(200, cts.Token);
}
catch (OperationCanceledException)
{
// Expected
}
finally
{
await service.StopAsync(CancellationToken.None);
}
// Assert
capturedAction.Should().NotBeNull();
// Apply the action to verify it sets Unhealthy
var testConnection = CreateConnection("conn-1", "service-a", "1.0.0");
testConnection.Status = InstanceHealthStatus.Healthy;
capturedAction!(testConnection);
testConnection.Status.Should().Be(InstanceHealthStatus.Unhealthy);
}
private static ConnectionState CreateConnection(
string connectionId, string serviceName, string version)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
ServiceName = serviceName,
Version = version,
Region = "us-east-1"
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
}

View File

@@ -0,0 +1,323 @@
using FluentAssertions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class InMemoryRoutingStateTests
{
private readonly InMemoryRoutingState _sut = new();
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
string serviceName = "test-service",
string version = "1.0.0",
string region = "us-east-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
params (string Method, string Path)[] endpoints)
{
var connection = new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = serviceName,
Version = version,
Region = region
},
Status = status,
TransportType = TransportType.InMemory
};
foreach (var (method, path) in endpoints)
{
connection.Endpoints[(method, path)] = new EndpointDescriptor
{
Method = method,
Path = path,
ServiceName = serviceName,
Version = version
};
}
return connection;
}
[Fact]
public void AddConnection_ShouldStoreConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
// Act
_sut.AddConnection(connection);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().NotBeNull();
result.Should().BeSameAs(connection);
}
[Fact]
public void AddConnection_ShouldIndexEndpoints()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}")]);
// Act
_sut.AddConnection(connection);
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/users/123");
endpoint.Should().NotBeNull();
endpoint!.Path.Should().Be("/api/users/{id}");
}
[Fact]
public void RemoveConnection_ShouldRemoveConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.RemoveConnection(connection.ConnectionId);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().BeNull();
}
[Fact]
public void RemoveConnection_ShouldRemoveEndpointsWhenLastConnection()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.RemoveConnection(connection.ConnectionId);
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
endpoint.Should().BeNull();
}
[Fact]
public void RemoveConnection_ShouldKeepEndpointsWhenOtherConnectionsExist()
{
// Arrange
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
_sut.RemoveConnection("conn-1");
// Assert
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
endpoint.Should().NotBeNull();
}
[Fact]
public void UpdateConnection_ShouldApplyUpdate()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
_sut.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Degraded);
// Assert
var result = _sut.GetConnection(connection.ConnectionId);
result.Should().NotBeNull();
result!.Status.Should().Be(InstanceHealthStatus.Degraded);
}
[Fact]
public void UpdateConnection_ShouldDoNothingForUnknownConnection()
{
// Act - should not throw
_sut.UpdateConnection("unknown", c => c.Status = InstanceHealthStatus.Degraded);
// Assert
var result = _sut.GetConnection("unknown");
result.Should().BeNull();
}
[Fact]
public void GetConnection_ShouldReturnNullForUnknownConnection()
{
// Act
var result = _sut.GetConnection("unknown");
// Assert
result.Should().BeNull();
}
[Fact]
public void GetAllConnections_ShouldReturnAllConnections()
{
// Arrange
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test1")]);
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test2")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetAllConnections();
// Assert
result.Should().HaveCount(2);
result.Should().Contain(connection1);
result.Should().Contain(connection2);
}
[Fact]
public void GetAllConnections_ShouldReturnEmptyWhenNoConnections()
{
// Act
var result = _sut.GetAllConnections();
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ResolveEndpoint_ShouldMatchExactPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/health")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/health");
// Assert
result.Should().NotBeNull();
result!.Path.Should().Be("/api/health");
}
[Fact]
public void ResolveEndpoint_ShouldMatchParameterizedPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}/orders/{orderId}")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/users/123/orders/456");
// Assert
result.Should().NotBeNull();
result!.Path.Should().Be("/api/users/{id}/orders/{orderId}");
}
[Fact]
public void ResolveEndpoint_ShouldReturnNullForNonMatchingMethod()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("POST", "/api/test");
// Assert
result.Should().BeNull();
}
[Fact]
public void ResolveEndpoint_ShouldReturnNullForNonMatchingPath()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("GET", "/api/other");
// Assert
result.Should().BeNull();
}
[Fact]
public void ResolveEndpoint_ShouldBeCaseInsensitiveForMethod()
{
// Arrange
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.ResolveEndpoint("get", "/api/test");
// Assert
result.Should().NotBeNull();
}
[Fact]
public void GetConnectionsFor_ShouldFilterByServiceName()
{
// Arrange
var connection1 = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", "service-b", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
// Assert
result.Should().HaveCount(1);
result[0].Instance.ServiceName.Should().Be("service-a");
}
[Fact]
public void GetConnectionsFor_ShouldFilterByVersion()
{
// Arrange
var connection1 = CreateConnection("conn-1", "service-a", "1.0.0", endpoints: [("GET", "/api/test")]);
var connection2 = CreateConnection("conn-2", "service-a", "2.0.0", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection1);
_sut.AddConnection(connection2);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
// Assert
result.Should().HaveCount(1);
result[0].Instance.Version.Should().Be("1.0.0");
}
[Fact]
public void GetConnectionsFor_ShouldReturnEmptyWhenNoMatch()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
_sut.AddConnection(connection);
// Act
var result = _sut.GetConnectionsFor("service-b", "1.0.0", "GET", "/api/test");
// Assert
result.Should().BeEmpty();
}
[Fact]
public void GetConnectionsFor_ShouldMatchParameterizedPaths()
{
// Arrange
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/users/{id}")]);
_sut.AddConnection(connection);
// Act
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/users/123");
// Assert
result.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,254 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class PayloadTrackerTests
{
private readonly PayloadLimits _limits = new()
{
MaxRequestBytesPerCall = 1024,
MaxRequestBytesPerConnection = 4096,
MaxAggregateInflightBytes = 8192
};
private PayloadTracker CreateTracker()
{
return new PayloadTracker(
Options.Create(_limits),
NullLogger<PayloadTracker>.Instance);
}
[Fact]
public void TryReserve_WithinLimits_ReturnsTrue()
{
var tracker = CreateTracker();
var result = tracker.TryReserve("conn-1", 500);
Assert.True(result);
Assert.Equal(500, tracker.CurrentInflightBytes);
}
[Fact]
public void TryReserve_ExceedsAggregateLimits_ReturnsFalse()
{
var tracker = CreateTracker();
// Reserve from multiple connections to approach aggregate limit (8192)
// Each connection can have up to 4096 bytes
Assert.True(tracker.TryReserve("conn-1", 4000));
Assert.True(tracker.TryReserve("conn-2", 4000));
// Now at 8000 bytes
// Another reservation that exceeds aggregate limit (8000 + 500 > 8192) should fail
var result = tracker.TryReserve("conn-3", 500);
Assert.False(result);
Assert.Equal(8000, tracker.CurrentInflightBytes);
}
[Fact]
public void TryReserve_ExceedsPerConnectionLimit_ReturnsFalse()
{
var tracker = CreateTracker();
// Reserve up to per-connection limit
Assert.True(tracker.TryReserve("conn-1", 4000));
// Next reservation on same connection should fail
var result = tracker.TryReserve("conn-1", 500);
Assert.False(result);
}
[Fact]
public void TryReserve_DifferentConnections_TrackedSeparately()
{
var tracker = CreateTracker();
Assert.True(tracker.TryReserve("conn-1", 3000));
Assert.True(tracker.TryReserve("conn-2", 3000));
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-1"));
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-2"));
Assert.Equal(6000, tracker.CurrentInflightBytes);
}
[Fact]
public void Release_DecreasesInflightBytes()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 1000);
tracker.Release("conn-1", 500);
Assert.Equal(500, tracker.CurrentInflightBytes);
Assert.Equal(500, tracker.GetConnectionInflightBytes("conn-1"));
}
[Fact]
public void Release_CannotGoNegative()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 100);
tracker.Release("conn-1", 500); // More than reserved
Assert.Equal(0, tracker.GetConnectionInflightBytes("conn-1"));
}
[Fact]
public void IsOverloaded_TrueWhenExceedsLimit()
{
var tracker = CreateTracker();
// Reservation at limit passes (8192 <= 8192 is false for >, so not overloaded at exactly limit)
// But we can't exceed the limit. The IsOverloaded check is for current > limit
// So at exactly 8192, IsOverloaded should be false (8192 > 8192 is false)
// Reserving 8193 would be rejected. So let's test that at limit, IsOverloaded is false
tracker.TryReserve("conn-1", 8192);
// At exactly the limit, IsOverloaded is false (8192 > 8192 = false)
Assert.False(tracker.IsOverloaded);
}
[Fact]
public void IsOverloaded_FalseWhenWithinLimit()
{
var tracker = CreateTracker();
tracker.TryReserve("conn-1", 4000);
Assert.False(tracker.IsOverloaded);
}
[Fact]
public void GetConnectionInflightBytes_ReturnsZeroForUnknownConnection()
{
var tracker = CreateTracker();
var result = tracker.GetConnectionInflightBytes("unknown");
Assert.Equal(0, result);
}
}
public class ByteCountingStreamTests
{
[Fact]
public async Task ReadAsync_CountsBytesRead()
{
var data = new byte[] { 1, 2, 3, 4, 5 };
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 100);
var buffer = new byte[10];
var read = await stream.ReadAsync(buffer);
Assert.Equal(5, read);
Assert.Equal(5, stream.BytesRead);
}
[Fact]
public async Task ReadAsync_ThrowsWhenLimitExceeded()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 50);
var buffer = new byte[100];
var ex = await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
Assert.Equal(100, ex.BytesRead);
Assert.Equal(50, ex.Limit);
}
[Fact]
public async Task ReadAsync_CallsCallbackOnLimitExceeded()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
var callbackCalled = false;
using var stream = new ByteCountingStream(inner, 50, () => callbackCalled = true);
var buffer = new byte[100];
await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
Assert.True(callbackCalled);
}
[Fact]
public async Task ReadAsync_AccumulatesAcrossMultipleReads()
{
var data = new byte[100];
using var inner = new MemoryStream(data);
using var stream = new ByteCountingStream(inner, 60);
var buffer = new byte[30];
// First read - 30 bytes
var read1 = await stream.ReadAsync(buffer);
Assert.Equal(30, read1);
Assert.Equal(30, stream.BytesRead);
// Second read - 30 more bytes
var read2 = await stream.ReadAsync(buffer);
Assert.Equal(30, read2);
Assert.Equal(60, stream.BytesRead);
// Third read should exceed limit
await Assert.ThrowsAsync<PayloadLimitExceededException>(
() => stream.ReadAsync(buffer).AsTask());
}
[Fact]
public void Stream_Properties_AreCorrect()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.True(stream.CanRead);
Assert.False(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Write_ThrowsNotSupported()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.Throws<NotSupportedException>(() => stream.Write(new byte[10], 0, 10));
}
[Fact]
public void Seek_ThrowsNotSupported()
{
using var inner = new MemoryStream();
using var stream = new ByteCountingStream(inner, 100);
Assert.Throws<NotSupportedException>(() => stream.Seek(0, SeekOrigin.Begin));
}
}
public class PayloadLimitExceededExceptionTests
{
[Fact]
public void Constructor_SetsProperties()
{
var ex = new PayloadLimitExceededException(1000, 500);
Assert.Equal(1000, ex.BytesRead);
Assert.Equal(500, ex.Limit);
Assert.Contains("1000", ex.Message);
Assert.Contains("500", ex.Message);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Disable Concelier test infrastructure - we don't need MongoDB -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,315 @@
using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Microservice.Streaming;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.InMemory;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
public class StreamingTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public void StreamDataPayload_HasRequiredProperties()
{
var payload = new StreamDataPayload
{
CorrelationId = Guid.NewGuid(),
Data = new byte[] { 1, 2, 3 },
EndOfStream = true,
SequenceNumber = 5
};
Assert.NotEqual(Guid.Empty, payload.CorrelationId);
Assert.Equal(3, payload.Data.Length);
Assert.True(payload.EndOfStream);
Assert.Equal(5, payload.SequenceNumber);
}
[Fact]
public void StreamingOptions_HasDefaultValues()
{
var options = StreamingOptions.Default;
Assert.Equal(64 * 1024, options.ChunkSize);
Assert.Equal(100, options.MaxConcurrentStreams);
Assert.Equal(TimeSpan.FromMinutes(5), options.StreamIdleTimeout);
Assert.Equal(16, options.ChannelCapacity);
}
}
public class StreamingRequestBodyStreamTests
{
[Fact]
public async Task ReadAsync_ReturnsDataFromChannel()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
var testData = new byte[] { 1, 2, 3, 4, 5 };
await channel.Writer.WriteAsync(new StreamChunk { Data = testData, SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 1 });
channel.Writer.Complete();
// Act
var buffer = new byte[10];
var bytesRead = await stream.ReadAsync(buffer);
// Assert
Assert.Equal(5, bytesRead);
Assert.Equal(testData, buffer[..5]);
}
[Fact]
public async Task ReadAsync_ReturnsZeroAtEndOfStream()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 0 });
channel.Writer.Complete();
// Act
var buffer = new byte[10];
var bytesRead = await stream.ReadAsync(buffer);
// Assert
Assert.Equal(0, bytesRead);
}
[Fact]
public async Task ReadAsync_HandlesMultipleChunks()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2, 3], SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [4, 5, 6], SequenceNumber = 1 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 2 });
channel.Writer.Complete();
// Act
using var memStream = new MemoryStream();
await stream.CopyToAsync(memStream);
// Assert
var result = memStream.ToArray();
Assert.Equal(6, result.Length);
Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, result);
}
[Fact]
public void Stream_Properties_AreCorrect()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
Assert.True(stream.CanRead);
Assert.False(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Write_ThrowsNotSupported()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
Assert.Throws<NotSupportedException>(() => stream.Write([1, 2, 3], 0, 3));
}
}
public class StreamingResponseBodyStreamTests
{
[Fact]
public async Task WriteAsync_WritesToChannel()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
var testData = new byte[] { 1, 2, 3, 4, 5 };
// Act
await stream.WriteAsync(testData);
await stream.FlushAsync();
// Assert
Assert.True(channel.Reader.TryRead(out var chunk));
Assert.Equal(testData, chunk!.Data);
Assert.False(chunk.EndOfStream);
}
[Fact]
public async Task CompleteAsync_SendsEndOfStream()
{
// Arrange
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
// Act
await stream.WriteAsync(new byte[] { 1, 2, 3 });
await stream.CompleteAsync();
// Assert - should have data chunk + end chunk
var chunks = new List<StreamChunk>();
await foreach (var chunk in channel.Reader.ReadAllAsync())
{
chunks.Add(chunk);
}
Assert.Equal(2, chunks.Count);
Assert.False(chunks[0].EndOfStream);
Assert.True(chunks[1].EndOfStream);
}
[Fact]
public async Task WriteAsync_ChunksLargeData()
{
// Arrange
var chunkSize = 10;
var channel = Channel.CreateUnbounded<StreamChunk>();
await using var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize, CancellationToken.None);
var testData = new byte[25]; // Will need 3 chunks
for (var i = 0; i < testData.Length; i++)
{
testData[i] = (byte)i;
}
// Act
await stream.WriteAsync(testData);
await stream.CompleteAsync();
// Assert
var chunks = new List<StreamChunk>();
await foreach (var chunk in channel.Reader.ReadAllAsync())
{
chunks.Add(chunk);
}
// Should have 3 chunks (10+10+5) + 1 end-of-stream (with 0 data since remainder already flushed)
Assert.Equal(4, chunks.Count);
Assert.Equal(10, chunks[0].Data.Length);
Assert.Equal(10, chunks[1].Data.Length);
Assert.Equal(5, chunks[2].Data.Length);
Assert.True(chunks[3].EndOfStream);
}
[Fact]
public void Stream_Properties_AreCorrect()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
Assert.False(stream.CanRead);
Assert.True(stream.CanWrite);
Assert.False(stream.CanSeek);
}
[Fact]
public void Read_ThrowsNotSupported()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
Assert.Throws<NotSupportedException>(() => stream.Read(new byte[10], 0, 10));
}
}
public class InMemoryTransportStreamingTests
{
private readonly InMemoryConnectionRegistry _registry = new();
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
private InMemoryTransportClient CreateClient()
{
return new InMemoryTransportClient(
_registry,
Options.Create(_options),
NullLogger<InMemoryTransportClient>.Instance);
}
[Fact]
public async Task SendStreamingAsync_SendsRequestStreamDataFrames()
{
// Arrange
using var client = CreateClient();
var instance = new InstanceDescriptor
{
InstanceId = "test-instance",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
};
await client.ConnectAsync(instance, [], CancellationToken.None);
// Get connection ID via reflection
var connectionIdField = client.GetType()
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var connectionId = connectionIdField?.GetValue(client)?.ToString();
Assert.NotNull(connectionId);
var channel = _registry.GetChannel(connectionId!);
Assert.NotNull(channel);
Assert.NotNull(channel!.State);
// Create request body stream
var requestBody = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
// Create request frame
var requestFrame = new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = ReadOnlyMemory<byte>.Empty
};
var limits = PayloadLimits.Default;
// Act - Start streaming (this will send frames to microservice)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sendTask = client.SendStreamingAsync(
channel.State!,
requestFrame,
requestBody,
_ => Task.CompletedTask,
limits,
cts.Token);
// Read the frames that were sent to microservice
var frames = new List<Frame>();
await foreach (var frame in channel.ToMicroservice.Reader.ReadAllAsync(cts.Token))
{
frames.Add(frame);
if (frame.Type == FrameType.RequestStreamData && frame.Payload.Length == 0)
{
// End of stream - break
break;
}
}
// Assert - should have REQUEST header + data chunks + end-of-stream
Assert.True(frames.Count >= 2);
Assert.Equal(FrameType.Request, frames[0].Type);
Assert.Equal(FrameType.RequestStreamData, frames[^1].Type);
Assert.Equal(0, frames[^1].Payload.Length); // End of stream marker
}
}

View File

@@ -8,6 +8,5 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.IssuerDirectory.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for the IssuerDirectory module.
/// Manages connection pooling, tenant context, and session configuration.
/// </summary>
public sealed class IssuerDirectoryDataSource : DataSourceBase
{
private readonly ILogger<IssuerDirectoryDataSource> _logger;
/// <summary>
/// Creates a new IssuerDirectory data source.
/// </summary>
/// <param name="options">PostgreSQL connection options.</param>
/// <param name="logger">Logger for diagnostics.</param>
public IssuerDirectoryDataSource(PostgresOptions options, ILogger<IssuerDirectoryDataSource> logger)
: base(options, logger)
{
_logger = logger;
}
/// <inheritdoc />
protected override string ModuleName => "IssuerDirectory";
/// <inheritdoc />
protected override void OnConnectionOpened(string role)
{
_logger.LogDebug("IssuerDirectory connection opened with role {Role}.", role);
}
/// <inheritdoc />
protected override void OnConnectionClosed(string role)
{
_logger.LogDebug("IssuerDirectory connection closed for role {Role}.", role);
}
}

View File

@@ -0,0 +1,129 @@
-- Migration: 001_initial_schema
-- Category: startup
-- Description: Initial schema for IssuerDirectory PostgreSQL storage
-- Source: docs/db/schemas/issuer.sql
CREATE SCHEMA IF NOT EXISTS issuer;
-- Issuers (tenant or global)
CREATE TABLE IF NOT EXISTS issuer.issuers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- use @global GUID for seed publishers
name TEXT NOT NULL, -- logical issuer name (slug)
display_name TEXT NOT NULL,
description TEXT,
endpoints JSONB DEFAULT '{}'::jsonb, -- CSAF feeds, OIDC issuer URLs, contact links
contact JSONB DEFAULT '{}'::jsonb, -- Contact information
metadata JSONB DEFAULT '{}'::jsonb, -- Domain metadata (CVE org ID, CSAF publisher ID, etc.)
tags TEXT[] DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked','deprecated')),
is_system_seed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
UNIQUE (tenant_id, name)
);
-- Keys
CREATE TABLE IF NOT EXISTS issuer.issuer_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issuer_id UUID NOT NULL REFERENCES issuer.issuers(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
key_id TEXT NOT NULL, -- stable key identifier
key_type TEXT NOT NULL CHECK (key_type IN ('ed25519','x509','dsse','kms','hsm','fido2')),
public_key TEXT NOT NULL, -- PEM / base64
fingerprint TEXT NOT NULL, -- canonical fingerprint for dedupe
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired','revoked')),
replaces_key_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
retired_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoke_reason TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE (issuer_id, key_id),
UNIQUE (fingerprint)
);
-- Trust overrides (tenant-scoped weights)
CREATE TABLE IF NOT EXISTS issuer.trust_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issuer_id UUID NOT NULL REFERENCES issuer.issuers(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL, -- consumer tenant applying the override
weight NUMERIC(5,2) NOT NULL CHECK (weight >= -10 AND weight <= 10),
rationale TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
UNIQUE (issuer_id, tenant_id)
);
-- Audit log (issuer-domain specific)
CREATE TABLE IF NOT EXISTS issuer.audit (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
actor TEXT,
action TEXT NOT NULL, -- create_issuer, update_issuer, delete_issuer, add_key, rotate_key, revoke_key, set_trust, delete_trust, seed_csaf
issuer_id UUID,
key_id TEXT,
trust_override_id UUID,
reason TEXT,
details JSONB DEFAULT '{}'::jsonb,
correlation_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Schema migrations tracking
CREATE TABLE IF NOT EXISTS issuer.schema_migrations (
migration_name TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT 'startup',
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_by TEXT,
duration_ms INT,
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_issuers_tenant ON issuer.issuers(tenant_id);
CREATE INDEX IF NOT EXISTS idx_issuers_status ON issuer.issuers(status);
CREATE INDEX IF NOT EXISTS idx_issuers_slug ON issuer.issuers(name);
CREATE INDEX IF NOT EXISTS idx_keys_issuer ON issuer.issuer_keys(issuer_id);
CREATE INDEX IF NOT EXISTS idx_keys_status ON issuer.issuer_keys(status);
CREATE INDEX IF NOT EXISTS idx_keys_tenant ON issuer.issuer_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_trust_tenant ON issuer.trust_overrides(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_tenant_time ON issuer.audit(tenant_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_issuer ON issuer.audit(issuer_id);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at ON issuer.schema_migrations(applied_at DESC);
-- Updated-at trigger for issuers/trust overrides
CREATE OR REPLACE FUNCTION issuer.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_issuers_updated_at ON issuer.issuers;
CREATE TRIGGER trg_issuers_updated_at
BEFORE UPDATE ON issuer.issuers
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();
DROP TRIGGER IF EXISTS trg_keys_updated_at ON issuer.issuer_keys;
CREATE TRIGGER trg_keys_updated_at
BEFORE UPDATE ON issuer.issuer_keys
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();
DROP TRIGGER IF EXISTS trg_trust_updated_at ON issuer.trust_overrides;
CREATE TRIGGER trg_trust_updated_at
BEFORE UPDATE ON issuer.trust_overrides
FOR EACH ROW EXECUTE FUNCTION issuer.update_updated_at();

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.IssuerDirectory.Storage.Postgres;
/// <summary>
/// Extension methods for registering IssuerDirectory PostgreSQL storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the IssuerDirectory PostgreSQL data source.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration delegate.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddIssuerDirectoryPostgresStorage(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(configureOptions);
var options = new PostgresOptions
{
ConnectionString = string.Empty,
SchemaName = "issuer"
};
configureOptions(options);
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<IssuerDirectoryDataSource>>();
return new IssuerDirectoryDataSource(options, logger);
});
return services;
}
/// <summary>
/// Registers the IssuerDirectory PostgreSQL data source with provided options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="options">PostgreSQL options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddIssuerDirectoryPostgresStorage(
this IServiceCollection services,
PostgresOptions options)
{
ArgumentNullException.ThrowIfNull(options);
// Ensure schema is set for issuer module
if (string.IsNullOrWhiteSpace(options.SchemaName))
{
options.SchemaName = "issuer";
}
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<IssuerDirectoryDataSource>>();
return new IssuerDirectoryDataSource(options, logger);
});
return services;
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.IssuerDirectory.Storage.Postgres</RootNamespace>
<AssemblyName>StellaOps.IssuerDirectory.Storage.Postgres</AssemblyName>
<Description>PostgreSQL storage implementation for IssuerDirectory module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Npgsql" Version="9.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" LogicalName="%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View File

@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.W
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.Core.Tests", "StellaOps.IssuerDirectory.Core.Tests\StellaOps.IssuerDirectory.Core.Tests.csproj", "{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.IssuerDirectory.Storage.Postgres", "StellaOps.IssuerDirectory.Storage.Postgres\StellaOps.IssuerDirectory.Storage.Postgres.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22842BC6-D909-4919-8FB1-B2C3ED7E4DDE}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -515,6 +515,32 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Storage.Postgres", "Excititor\__Libraries\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj", "{78C860BC-C202-4AF4-B1D4-622D13F87154}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Udp", "__Libraries\StellaOps.Router.Transport.Udp\StellaOps.Router.Transport.Udp.csproj", "{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{C3577C67-CC31-4A24-805A-BAA947405103}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Udp.Tests", "..\tests\StellaOps.Router.Transport.Udp.Tests\StellaOps.Router.Transport.Udp.Tests.csproj", "{3AEDB2CC-5CD9-4B69-9106-56282212E17A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.RabbitMq", "__Libraries\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj", "{2C866CEC-F804-4911-A684-FEB4B53CDA6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{59571F52-626D-4CB8-9763-156840A777C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.SourceGen", "__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj", "{E4852EB6-0F7F-444F-8F00-921108B10928}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Config", "__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj", "{33C53365-48A1-442A-9361-02B3D2FF064E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.InMemory", "__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj", "{29144F5A-908C-401E-BEFD-493D14D2650B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tcp", "__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj", "{25C2580B-A158-4715-AF91-87CBFDB1D37B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tls", "__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj", "{A5F33325-BB34-481D-B4D1-F3074588D030}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{AA2C6AF3-C7DD-B4A1-B450-550E12C0D570}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tcp.Tests", "__Libraries\__Tests\StellaOps.Router.Transport.Tcp.Tests\StellaOps.Router.Transport.Tcp.Tests.csproj", "{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tls.Tests", "__Libraries\__Tests\StellaOps.Router.Transport.Tls.Tests\StellaOps.Router.Transport.Tls.Tests.csproj", "{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -3261,6 +3287,150 @@ Global
{78C860BC-C202-4AF4-B1D4-622D13F87154}.Release|x64.Build.0 = Release|Any CPU
{78C860BC-C202-4AF4-B1D4-622D13F87154}.Release|x86.ActiveCfg = Release|Any CPU
{78C860BC-C202-4AF4-B1D4-622D13F87154}.Release|x86.Build.0 = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|x64.ActiveCfg = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|x64.Build.0 = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|x86.ActiveCfg = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Debug|x86.Build.0 = Debug|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|Any CPU.Build.0 = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|x64.ActiveCfg = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|x64.Build.0 = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|x86.ActiveCfg = Release|Any CPU
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA}.Release|x86.Build.0 = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|x64.ActiveCfg = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|x64.Build.0 = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|x86.ActiveCfg = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Debug|x86.Build.0 = Debug|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|Any CPU.Build.0 = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|x64.ActiveCfg = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|x64.Build.0 = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|x86.ActiveCfg = Release|Any CPU
{C3577C67-CC31-4A24-805A-BAA947405103}.Release|x86.Build.0 = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|x64.ActiveCfg = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|x64.Build.0 = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|x86.ActiveCfg = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Debug|x86.Build.0 = Debug|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|Any CPU.Build.0 = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|x64.ActiveCfg = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|x64.Build.0 = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|x86.ActiveCfg = Release|Any CPU
{3AEDB2CC-5CD9-4B69-9106-56282212E17A}.Release|x86.Build.0 = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|x64.ActiveCfg = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|x64.Build.0 = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|x86.ActiveCfg = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Debug|x86.Build.0 = Debug|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|Any CPU.Build.0 = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|x64.ActiveCfg = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|x64.Build.0 = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|x86.ActiveCfg = Release|Any CPU
{2C866CEC-F804-4911-A684-FEB4B53CDA6D}.Release|x86.Build.0 = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|x64.ActiveCfg = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|x64.Build.0 = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|x86.ActiveCfg = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Debug|x86.Build.0 = Debug|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|Any CPU.Build.0 = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|x64.ActiveCfg = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|x64.Build.0 = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|x86.ActiveCfg = Release|Any CPU
{59571F52-626D-4CB8-9763-156840A777C7}.Release|x86.Build.0 = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|x64.ActiveCfg = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|x64.Build.0 = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|x86.ActiveCfg = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Debug|x86.Build.0 = Debug|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|Any CPU.Build.0 = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|x64.ActiveCfg = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|x64.Build.0 = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|x86.ActiveCfg = Release|Any CPU
{E4852EB6-0F7F-444F-8F00-921108B10928}.Release|x86.Build.0 = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|x64.ActiveCfg = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|x64.Build.0 = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|x86.ActiveCfg = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Debug|x86.Build.0 = Debug|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|Any CPU.Build.0 = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|x64.ActiveCfg = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|x64.Build.0 = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|x86.ActiveCfg = Release|Any CPU
{33C53365-48A1-442A-9361-02B3D2FF064E}.Release|x86.Build.0 = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|x64.ActiveCfg = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|x64.Build.0 = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|x86.ActiveCfg = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Debug|x86.Build.0 = Debug|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|Any CPU.Build.0 = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|x64.ActiveCfg = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|x64.Build.0 = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|x86.ActiveCfg = Release|Any CPU
{29144F5A-908C-401E-BEFD-493D14D2650B}.Release|x86.Build.0 = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|x64.ActiveCfg = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|x64.Build.0 = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|x86.ActiveCfg = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Debug|x86.Build.0 = Debug|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|Any CPU.Build.0 = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|x64.ActiveCfg = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|x64.Build.0 = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|x86.ActiveCfg = Release|Any CPU
{25C2580B-A158-4715-AF91-87CBFDB1D37B}.Release|x86.Build.0 = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|x64.Build.0 = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Debug|x86.Build.0 = Debug|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|Any CPU.Build.0 = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|x64.ActiveCfg = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|x64.Build.0 = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|x86.ActiveCfg = Release|Any CPU
{A5F33325-BB34-481D-B4D1-F3074588D030}.Release|x86.Build.0 = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|x64.ActiveCfg = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|x64.Build.0 = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|x86.ActiveCfg = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Debug|x86.Build.0 = Debug|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|Any CPU.Build.0 = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|x64.ActiveCfg = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|x64.Build.0 = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|x86.ActiveCfg = Release|Any CPU
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A}.Release|x86.Build.0 = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|x64.ActiveCfg = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|x64.Build.0 = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|x86.ActiveCfg = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Debug|x86.Build.0 = Debug|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|Any CPU.Build.0 = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|x64.ActiveCfg = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|x64.Build.0 = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|x86.ActiveCfg = Release|Any CPU
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -3429,5 +3599,17 @@ Global
{7AD93C68-A414-451D-9C88-61E8B30296BF} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{36A55FFC-C1AA-1035-7444-B14EA8ED4742} = {39950C83-D8E3-1947-C0FB-36A746730E00}
{78C860BC-C202-4AF4-B1D4-622D13F87154} = {36A55FFC-C1AA-1035-7444-B14EA8ED4742}
{B18AD15F-05AD-4A7F-9EA8-AB3CA17204DA} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{C3577C67-CC31-4A24-805A-BAA947405103} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{2C866CEC-F804-4911-A684-FEB4B53CDA6D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{59571F52-626D-4CB8-9763-156840A777C7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{E4852EB6-0F7F-444F-8F00-921108B10928} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{33C53365-48A1-442A-9361-02B3D2FF064E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{29144F5A-908C-401E-BEFD-493D14D2650B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{25C2580B-A158-4715-AF91-87CBFDB1D37B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{A5F33325-BB34-481D-B4D1-F3074588D030} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{AA2C6AF3-C7DD-B4A1-B450-550E12C0D570} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{FC2D8FEC-3ABC-4240-80A1-E400CC25685A} = {AA2C6AF3-C7DD-B4A1-B450-550E12C0D570}
{2DD8D108-8B07-45AB-BAA1-7A1103D5CA73} = {AA2C6AF3-C7DD-B4A1-B450-550E12C0D570}
EndGlobalSection
EndGlobal

View File

@@ -1,7 +1,7 @@
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../src/stories/**/*.stories.@(ts|mdx)'],
stories: ['../src/stories/**/*.@(mdx|stories.@(ts))'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',

View File

@@ -111,9 +111,8 @@
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"port": 4600,
"quiet": true,
"ci": true
"compodoc": false,
"port": 6006
}
},
"build-storybook": {
@@ -121,8 +120,8 @@
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"outputDir": "storybook-static",
"quiet": true
"compodoc": false,
"outputDir": "storybook-static"
}
}
}

View File

@@ -25,6 +25,7 @@
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@axe-core/playwright": "4.8.4",
"@chromatic-com/storybook": "^1.9.0",
"@playwright/test": "^1.47.2",
"@storybook/addon-a11y": "8.1.0",
"@storybook/addon-essentials": "8.1.0",
@@ -1195,6 +1196,7 @@
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.17.tgz",
"integrity": "sha512-FgOvf9q5d23Cpa7cjP1FYti/v8S1FTm8DEkW3TY8lkkoxh3isu28GFKcLD1p/XF3yqfPkPVHToOFla5QwsEgBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/architect": "0.1703.17",
@@ -3260,6 +3262,76 @@
"node": ">=6.9.0"
}
},
"node_modules/@chromatic-com/storybook": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-1.9.0.tgz",
"integrity": "sha512-vYQ+TcfktEE3GHnLZXHCzXF/sN9dw+KivH8a5cmPyd9YtQs7fZtHrEgsIjWpYycXiweKMo1Lm1RZsjxk8DH3rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chromatic": "^11.4.0",
"filesize": "^10.0.12",
"jsonfile": "^6.1.0",
"react-confetti": "^6.1.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=16.0.0",
"yarn": ">=1.22.18"
}
},
"node_modules/@chromatic-com/storybook/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@chromatic-com/storybook/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@chromatic-com/storybook/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@chromatic-com/storybook/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"dev": true,
@@ -8538,6 +8610,30 @@
"node": ">=10"
}
},
"node_modules/chromatic": {
"version": "11.29.0",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.29.0.tgz",
"integrity": "sha512-yisBlntp9hHVj19lIQdpTlcYIXuU9H/DbFuu6tyWHmj6hWT2EtukCCcxYXL78XdQt1vm2GfIrtgtKpj/Rzmo4A==",
"dev": true,
"license": "MIT",
"bin": {
"chroma": "dist/bin.js",
"chromatic": "dist/bin.js",
"chromatic-cli": "dist/bin.js"
},
"peerDependencies": {
"@chromatic-com/cypress": "^0.*.* || ^1.0.0",
"@chromatic-com/playwright": "^0.*.* || ^1.0.0"
},
"peerDependenciesMeta": {
"@chromatic-com/cypress": {
"optional": true
},
"@chromatic-com/playwright": {
"optional": true
}
}
},
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"dev": true,
@@ -10835,6 +10931,16 @@
"node": ">=10"
}
},
"node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
"integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.4.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
@@ -15327,6 +15433,22 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-confetti": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.4.0.tgz",
"integrity": "sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tween-functions": "^1.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -17673,6 +17795,13 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/tween-functions": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz",
"integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==",
"dev": true,
"license": "BSD"
},
"node_modules/type-detect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",

View File

@@ -40,6 +40,7 @@
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@axe-core/playwright": "4.8.4",
"@chromatic-com/storybook": "^1.9.0",
"@playwright/test": "^1.47.2",
"@storybook/addon-a11y": "8.1.0",
"@storybook/addon-essentials": "8.1.0",

View File

@@ -127,3 +127,68 @@ export const requireOrchQuotaGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_QUOTA],
'/console/profile'
);
// Pre-built guards for Policy Studio scope requirements (UI-POLICY-20-003)
/**
* Guard requiring policy:read scope for Policy Studio viewer access.
* Redirects to /console/profile if user lacks Policy viewer access.
*/
export const requirePolicyViewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ],
'/console/profile'
);
/**
* Guard requiring policy:author and policy:edit scopes for policy authoring.
* Allows creating and editing policy drafts.
*/
export const requirePolicyAuthorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_AUTHOR, StellaOpsScopes.POLICY_EDIT],
'/console/profile'
);
/**
* Guard requiring policy:review scope for policy review workflow.
* Allows reviewing policy drafts before approval.
*/
export const requirePolicyReviewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_REVIEW],
'/console/profile'
);
/**
* Guard requiring policy:approve scope for policy approval workflow.
* Allows approving or rejecting policy drafts.
*/
export const requirePolicyApproverGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_REVIEW, StellaOpsScopes.POLICY_APPROVE],
'/console/profile'
);
/**
* Guard requiring policy:operate and policy:activate scopes for policy operations.
* Allows activating and running policies in environments.
*/
export const requirePolicyOperatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_OPERATE, StellaOpsScopes.POLICY_ACTIVATE],
'/console/profile'
);
/**
* Guard requiring policy:simulate scope for policy simulation.
* Allows running what-if simulations against policies.
*/
export const requirePolicySimulatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_SIMULATE],
'/console/profile'
);
/**
* Guard requiring policy:audit scope for policy audit trails.
* Allows viewing policy change history and audit logs.
*/
export const requirePolicyAuditGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_AUDIT],
'/console/profile'
);

View File

@@ -46,6 +46,17 @@ export interface AuthService {
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canInitiateBackfill(): boolean;
// Policy Studio access (UI-POLICY-20-003)
canViewPolicies(): boolean;
canAuthorPolicies(): boolean;
canEditPolicies(): boolean;
canReviewPolicies(): boolean;
canApprovePolicies(): boolean;
canOperatePolicies(): boolean;
canActivatePolicies(): boolean;
canSimulatePolicies(): boolean;
canPublishPolicies(): boolean;
canAuditPolicies(): boolean;
}
// ============================================================================
@@ -67,10 +78,19 @@ const MOCK_USER: AuthUser = {
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions
// Policy permissions (Policy Studio - UI-POLICY-20-003)
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_AUDIT,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
@@ -144,6 +164,47 @@ export class MockAuthService implements AuthService {
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
// Policy Studio access methods (UI-POLICY-20-003)
canViewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_READ);
}
canAuthorPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
}
canEditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
}
canReviewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
}
canApprovePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
}
canOperatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
}
canActivatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
}
canSimulatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
}
canPublishPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
}
canAuditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
}
}
// Re-export scopes for convenience

View File

@@ -28,11 +28,24 @@ export const StellaOpsScopes = {
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
// Policy scopes
// Policy scopes (full Policy Studio workflow - UI-POLICY-20-003)
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Policy Studio authoring & review workflow
POLICY_AUTHOR: 'policy:author',
POLICY_EDIT: 'policy:edit',
POLICY_REVIEW: 'policy:review',
POLICY_SUBMIT: 'policy:submit',
POLICY_APPROVE: 'policy:approve',
// Policy operations & execution
POLICY_OPERATE: 'policy:operate',
POLICY_ACTIVATE: 'policy:activate',
POLICY_RUN: 'policy:run',
POLICY_PUBLISH: 'policy:publish', // Requires interactive auth
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
POLICY_AUDIT: 'policy:audit',
// Exception scopes
EXCEPTION_READ: 'exception:read',
@@ -128,6 +141,64 @@ export const ScopeGroups = {
StellaOpsScopes.ORCH_BACKFILL,
StellaOpsScopes.UI_READ,
] as const,
// Policy Studio scope groups (UI-POLICY-20-003)
POLICY_VIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.UI_READ,
] as const,
POLICY_AUTHOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_WRITE,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_REVIEWER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_APPROVER: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_OPERATOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_ADMIN: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_WRITE,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_PUBLISH,
StellaOpsScopes.POLICY_PROMOTE,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
@@ -149,6 +220,18 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
// Policy Studio workflow scopes (UI-POLICY-20-003)
'policy:author': 'Author Policy Drafts',
'policy:edit': 'Edit Policy Configuration',
'policy:review': 'Review Policy Drafts',
'policy:submit': 'Submit Policies for Review',
'policy:approve': 'Approve/Reject Policies',
'policy:operate': 'Operate Policy Promotions',
'policy:activate': 'Activate Policies',
'policy:run': 'Trigger Policy Runs',
'policy:publish': 'Publish Policy Versions',
'policy:promote': 'Promote Between Environments',
'policy:audit': 'Audit Policy Activity',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',

View File

@@ -0,0 +1,16 @@
/**
* Policy Studio editor module exports.
*
* @task UI-POLICY-20-001
*/
export {
STELLA_DSL_LANGUAGE_ID,
stellaDslMonarchLanguage,
stellaDslLanguageConfiguration,
stellaDslThemeRules,
registerStellaDslLanguage,
defineStellaDslTheme,
} from './stella-dsl.language';
export { registerStellaDslCompletions } from './stella-dsl.completions';

View File

@@ -0,0 +1,451 @@
/**
* Monaco completion provider for Stella Policy DSL.
*
* Provides IntelliSense suggestions for:
* - Keywords and syntax structures
* - Built-in functions
* - Namespace fields
* - VEX statuses and justifications
*
* @task UI-POLICY-20-001
*/
import type * as Monaco from 'monaco-editor';
import { STELLA_DSL_LANGUAGE_ID } from './stella-dsl.language';
/**
* Completion items for stella-dsl keywords.
*/
const keywordCompletions: Monaco.languages.CompletionItem[] = [
{
label: 'policy',
kind: 14, // Keyword
insertText: 'policy "${1:PolicyName}" syntax "stella-dsl@1" {\n\t$0\n}',
insertTextRules: 4, // InsertAsSnippet
documentation: 'Define a new policy document.',
detail: 'Policy Declaration',
},
{
label: 'metadata',
kind: 14,
insertText: 'metadata {\n\tdescription = "${1:description}"\n\ttags = [$2]\n}',
insertTextRules: 4,
documentation: 'Define metadata for the policy.',
detail: 'Metadata Section',
},
{
label: 'profile',
kind: 14,
insertText: 'profile ${1:severity} {\n\t$0\n}',
insertTextRules: 4,
documentation: 'Define a profile block for scoring modifiers.',
detail: 'Profile Section',
},
{
label: 'settings',
kind: 14,
insertText: 'settings {\n\t${1:shadow} = ${2:true};\n}',
insertTextRules: 4,
documentation: 'Configure evaluation settings.',
detail: 'Settings Section',
},
{
label: 'rule',
kind: 14,
insertText: 'rule ${1:rule_name} priority ${2:10} {\n\twhen ${3:condition}\n\tthen ${4:action}\n\tbecause "${5:rationale}";\n}',
insertTextRules: 4,
documentation: 'Define a policy rule with when/then logic.',
detail: 'Rule Definition',
},
{
label: 'map',
kind: 14,
insertText: 'map ${1:name} {\n\tsource "${2:source}" => ${3:0.0};\n}',
insertTextRules: 4,
documentation: 'Define a scoring map within a profile.',
detail: 'Profile Map',
},
{
label: 'env',
kind: 14,
insertText: 'env ${1:name} {\n\tif ${2:condition} then ${3:value};\n}',
insertTextRules: 4,
documentation: 'Define environment-based adjustments.',
detail: 'Environment Map',
},
{
label: 'when',
kind: 14,
insertText: 'when ${1:condition}',
insertTextRules: 4,
documentation: 'Condition clause for rule execution.',
detail: 'Rule Condition',
},
{
label: 'then',
kind: 14,
insertText: 'then ${1:action}',
insertTextRules: 4,
documentation: 'Action clause executed when condition is true.',
detail: 'Rule Action',
},
{
label: 'else',
kind: 14,
insertText: 'else ${1:action}',
insertTextRules: 4,
documentation: 'Fallback action clause.',
detail: 'Rule Else Action',
},
{
label: 'because',
kind: 14,
insertText: 'because "${1:rationale}"',
insertTextRules: 4,
documentation: 'Mandatory rationale for status/severity changes.',
detail: 'Rule Rationale',
},
];
/**
* Completion items for built-in functions.
*/
const functionCompletions: Monaco.languages.CompletionItem[] = [
{
label: 'normalize_cvss',
kind: 1, // Function
insertText: 'normalize_cvss(${1:advisory})',
insertTextRules: 4,
documentation: 'Parse advisory for CVSS data and return severity scalar.',
detail: 'Advisory → SeverityScalar',
},
{
label: 'cvss',
kind: 1,
insertText: 'cvss(${1:score}, "${2:vector}")',
insertTextRules: 4,
documentation: 'Construct a severity object from score and vector.',
detail: 'double × string → SeverityScalar',
},
{
label: 'severity_band',
kind: 1,
insertText: 'severity_band("${1|critical,high,medium,low,none|}")',
insertTextRules: 4,
documentation: 'Normalise severity string to band.',
detail: 'string → SeverityBand',
},
{
label: 'risk_score',
kind: 1,
insertText: 'risk_score(${1:base}, ${2:modifier})',
insertTextRules: 4,
documentation: 'Calculate risk by multiplying severity × trust × reachability.',
detail: 'Variadic',
},
{
label: 'reach_state',
kind: 1,
insertText: 'reach_state("${1|reachable,unreachable,unknown|}")',
insertTextRules: 4,
documentation: 'Normalise reachability state string.',
detail: 'string → ReachState',
},
{
label: 'exists',
kind: 1,
insertText: 'exists(${1:expression})',
insertTextRules: 4,
documentation: 'Return true when value is non-null/empty.',
detail: '→ bool',
},
{
label: 'coalesce',
kind: 1,
insertText: 'coalesce(${1:a}, ${2:b})',
insertTextRules: 4,
documentation: 'Return first non-null argument.',
detail: '→ value',
},
{
label: 'days_between',
kind: 1,
insertText: 'days_between(${1:dateA}, ${2:dateB})',
insertTextRules: 4,
documentation: 'Calculate absolute day difference (UTC).',
detail: '→ int',
},
{
label: 'percent_of',
kind: 1,
insertText: 'percent_of(${1:part}, ${2:whole})',
insertTextRules: 4,
documentation: 'Calculate percentage for scoring adjustments.',
detail: '→ double',
},
{
label: 'lowercase',
kind: 1,
insertText: 'lowercase(${1:text})',
insertTextRules: 4,
documentation: 'Normalise string casing (InvariantCulture).',
detail: 'string → string',
},
];
/**
* Completion items for VEX functions.
*/
const vexFunctionCompletions: Monaco.languages.CompletionItem[] = [
{
label: 'vex.any',
kind: 1,
insertText: 'vex.any(${1:status} ${2|==,!=,in|} ${3:value})',
insertTextRules: 4,
documentation: 'True if any VEX statement satisfies the predicate.',
detail: '(Statement → bool) → bool',
},
{
label: 'vex.all',
kind: 1,
insertText: 'vex.all(${1:status} ${2|==,!=,in|} ${3:value})',
insertTextRules: 4,
documentation: 'True if all VEX statements satisfy the predicate.',
detail: '(Statement → bool) → bool',
},
{
label: 'vex.latest',
kind: 1,
insertText: 'vex.latest()',
insertTextRules: 4,
documentation: 'Return the lexicographically newest VEX statement.',
detail: '→ Statement',
},
{
label: 'vex.count',
kind: 1,
insertText: 'vex.count(${1:predicate})',
insertTextRules: 4,
documentation: 'Count VEX statements matching predicate.',
detail: '→ int',
},
];
/**
* Completion items for namespace fields.
*/
const namespaceCompletions: Monaco.languages.CompletionItem[] = [
// SBOM fields
{ label: 'sbom.purl', kind: 5, insertText: 'sbom.purl', documentation: 'Package URL of the component.' },
{ label: 'sbom.name', kind: 5, insertText: 'sbom.name', documentation: 'Component name.' },
{ label: 'sbom.version', kind: 5, insertText: 'sbom.version', documentation: 'Component version.' },
{ label: 'sbom.licenses', kind: 5, insertText: 'sbom.licenses', documentation: 'Component licenses.' },
{ label: 'sbom.layerDigest', kind: 5, insertText: 'sbom.layerDigest', documentation: 'Container layer digest.' },
{ label: 'sbom.tags', kind: 5, insertText: 'sbom.tags', documentation: 'Component tags.' },
{ label: 'sbom.usedByEntrypoint', kind: 5, insertText: 'sbom.usedByEntrypoint', documentation: 'Whether component is used by entrypoint.' },
{ label: 'sbom.has_tag', kind: 1, insertText: 'sbom.has_tag("${1:tag}")', insertTextRules: 4, documentation: 'Check SBOM inventory tag.' },
{ label: 'sbom.any_component', kind: 1, insertText: 'sbom.any_component(${1:predicate})', insertTextRules: 4, documentation: 'Iterate SBOM components.' },
// Advisory fields
{ label: 'advisory.id', kind: 5, insertText: 'advisory.id', documentation: 'Advisory identifier.' },
{ label: 'advisory.source', kind: 5, insertText: 'advisory.source', documentation: 'Advisory source (GHSA, OSV, etc.).' },
{ label: 'advisory.aliases', kind: 5, insertText: 'advisory.aliases', documentation: 'Advisory aliases (CVE, etc.).' },
{ label: 'advisory.severity', kind: 5, insertText: 'advisory.severity', documentation: 'Advisory severity.' },
{ label: 'advisory.cvss', kind: 5, insertText: 'advisory.cvss', documentation: 'CVSS score.' },
{ label: 'advisory.publishedAt', kind: 5, insertText: 'advisory.publishedAt', documentation: 'Publication date.' },
{ label: 'advisory.modifiedAt', kind: 5, insertText: 'advisory.modifiedAt', documentation: 'Last modification date.' },
{ label: 'advisory.has_tag', kind: 1, insertText: 'advisory.has_tag("${1:tag}")', insertTextRules: 4, documentation: 'Check advisory metadata tag.' },
{ label: 'advisory.matches', kind: 1, insertText: 'advisory.matches("${1:pattern}")', insertTextRules: 4, documentation: 'Glob match against advisory identifiers.' },
// VEX fields
{ label: 'vex.status', kind: 5, insertText: 'vex.status', documentation: 'VEX status.' },
{ label: 'vex.justification', kind: 5, insertText: 'vex.justification', documentation: 'VEX justification.' },
{ label: 'vex.statementId', kind: 5, insertText: 'vex.statementId', documentation: 'VEX statement ID.' },
{ label: 'vex.timestamp', kind: 5, insertText: 'vex.timestamp', documentation: 'VEX timestamp.' },
{ label: 'vex.scope', kind: 5, insertText: 'vex.scope', documentation: 'VEX scope.' },
// Signals fields
{ label: 'signals.trust_score', kind: 5, insertText: 'signals.trust_score', documentation: 'Trust score (01).' },
{ label: 'signals.reachability.state', kind: 5, insertText: 'signals.reachability.state', documentation: 'Reachability state.' },
{ label: 'signals.reachability.score', kind: 5, insertText: 'signals.reachability.score', documentation: 'Reachability score (01).' },
{ label: 'signals.entropy_penalty', kind: 5, insertText: 'signals.entropy_penalty', documentation: 'Entropy penalty (00.3).' },
{ label: 'signals.uncertainty.level', kind: 5, insertText: 'signals.uncertainty.level', documentation: 'Uncertainty level (U1U3).' },
{ label: 'signals.runtime_hits', kind: 5, insertText: 'signals.runtime_hits', documentation: 'Runtime hit indicator.' },
// Telemetry fields
{ label: 'telemetry.reachability.state', kind: 5, insertText: 'telemetry.reachability.state', documentation: 'Telemetry reachability state.' },
{ label: 'telemetry.reachability.score', kind: 5, insertText: 'telemetry.reachability.score', documentation: 'Telemetry reachability score.' },
// Run fields
{ label: 'run.policyId', kind: 5, insertText: 'run.policyId', documentation: 'Policy ID.' },
{ label: 'run.policyVersion', kind: 5, insertText: 'run.policyVersion', documentation: 'Policy version.' },
{ label: 'run.tenant', kind: 5, insertText: 'run.tenant', documentation: 'Tenant ID.' },
{ label: 'run.timestamp', kind: 5, insertText: 'run.timestamp', documentation: 'Run timestamp.' },
// Secret fields
{ label: 'secret.hasFinding', kind: 1, insertText: 'secret.hasFinding(${1:ruleId})', insertTextRules: 4, documentation: 'Check for secret leak findings.' },
{ label: 'secret.match.count', kind: 1, insertText: 'secret.match.count(${1:ruleId})', insertTextRules: 4, documentation: 'Count secret findings.' },
{ label: 'secret.bundle.version', kind: 1, insertText: 'secret.bundle.version("${1:version}")', insertTextRules: 4, documentation: 'Check secret rule bundle version.' },
{ label: 'secret.mask.applied', kind: 5, insertText: 'secret.mask.applied', documentation: 'Whether masking succeeded.' },
];
/**
* Completion items for action keywords.
*/
const actionCompletions: Monaco.languages.CompletionItem[] = [
{
label: 'status :=',
kind: 14,
insertText: 'status := "${1|affected,not_affected,fixed,suppressed,under_investigation,escalated|}"',
insertTextRules: 4,
documentation: 'Set the finding status.',
detail: 'Status Assignment',
},
{
label: 'severity :=',
kind: 14,
insertText: 'severity := ${1:expression}',
insertTextRules: 4,
documentation: 'Set the finding severity.',
detail: 'Severity Assignment',
},
{
label: 'ignore',
kind: 14,
insertText: 'ignore until ${1:date} because "${2:rationale}"',
insertTextRules: 4,
documentation: 'Temporarily suppress finding until date.',
detail: 'Ignore Action',
},
{
label: 'escalate',
kind: 14,
insertText: 'escalate to severity_band("${1|critical,high|}") when ${2:condition}',
insertTextRules: 4,
documentation: 'Escalate severity when condition is true.',
detail: 'Escalate Action',
},
{
label: 'warn',
kind: 14,
insertText: 'warn message "${1:text}"',
insertTextRules: 4,
documentation: 'Add warning verdict.',
detail: 'Warn Action',
},
{
label: 'defer',
kind: 14,
insertText: 'defer until ${1:condition}',
insertTextRules: 4,
documentation: 'Defer finding evaluation.',
detail: 'Defer Action',
},
{
label: 'annotate',
kind: 14,
insertText: 'annotate ${1:key} := ${2:value}',
insertTextRules: 4,
documentation: 'Add free-form annotation to explain payload.',
detail: 'Annotate Action',
},
{
label: 'requireVex',
kind: 14,
insertText: 'requireVex {\n\tvendors = [${1:"Vendor"}]\n\tjustifications = [${2:"component_not_present"}]\n}',
insertTextRules: 4,
documentation: 'Require matching VEX evidence.',
detail: 'Require VEX Action',
},
];
/**
* Completion items for VEX statuses.
*/
const vexStatusCompletions: Monaco.languages.CompletionItem[] = [
{ label: 'affected', kind: 21, insertText: '"affected"', documentation: 'Component is affected by the vulnerability.' },
{ label: 'not_affected', kind: 21, insertText: '"not_affected"', documentation: 'Component is not affected.' },
{ label: 'fixed', kind: 21, insertText: '"fixed"', documentation: 'Vulnerability has been fixed.' },
{ label: 'suppressed', kind: 21, insertText: '"suppressed"', documentation: 'Finding is suppressed.' },
{ label: 'under_investigation', kind: 21, insertText: '"under_investigation"', documentation: 'Under investigation.' },
{ label: 'escalated', kind: 21, insertText: '"escalated"', documentation: 'Finding has been escalated.' },
];
/**
* Completion items for VEX justifications.
*/
const vexJustificationCompletions: Monaco.languages.CompletionItem[] = [
{ label: 'component_not_present', kind: 21, insertText: '"component_not_present"', documentation: 'Component is not present in the product.' },
{ label: 'vulnerable_code_not_present', kind: 21, insertText: '"vulnerable_code_not_present"', documentation: 'Vulnerable code is not present.' },
{ label: 'vulnerable_code_not_in_execute_path', kind: 21, insertText: '"vulnerable_code_not_in_execute_path"', documentation: 'Vulnerable code is not in execution path.' },
{ label: 'vulnerable_code_cannot_be_controlled_by_adversary', kind: 21, insertText: '"vulnerable_code_cannot_be_controlled_by_adversary"', documentation: 'Vulnerable code cannot be controlled by adversary.' },
{ label: 'inline_mitigations_already_exist', kind: 21, insertText: '"inline_mitigations_already_exist"', documentation: 'Inline mitigations already exist.' },
];
/**
* Registers the completion provider for stella-dsl.
*
* @param monaco - Monaco editor namespace
*/
export function registerStellaDslCompletions(monaco: typeof Monaco): Monaco.IDisposable {
return monaco.languages.registerCompletionItemProvider(STELLA_DSL_LANGUAGE_ID, {
triggerCharacters: ['.', '"', '(', ' '],
provideCompletionItems(
model: Monaco.editor.ITextModel,
position: Monaco.Position,
_context: Monaco.languages.CompletionContext,
_token: Monaco.CancellationToken
): Monaco.languages.ProviderResult<Monaco.languages.CompletionList> {
const word = model.getWordUntilPosition(position);
const range: Monaco.IRange = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const lineContent = model.getLineContent(position.lineNumber);
const textUntilPosition = lineContent.substring(0, position.column - 1);
// Determine context and provide relevant completions
const suggestions: Monaco.languages.CompletionItem[] = [];
// Check for namespace prefix
if (textUntilPosition.endsWith('sbom.') || textUntilPosition.endsWith('advisory.') ||
textUntilPosition.endsWith('vex.') || textUntilPosition.endsWith('signals.') ||
textUntilPosition.endsWith('telemetry.') || textUntilPosition.endsWith('run.') ||
textUntilPosition.endsWith('secret.') || textUntilPosition.endsWith('env.')) {
suggestions.push(...namespaceCompletions.map(c => ({ ...c, range })));
}
// Check for VEX status context
if (textUntilPosition.match(/status\s*(==|!=|:=|in)\s*["[]?$/)) {
suggestions.push(...vexStatusCompletions.map(c => ({ ...c, range })));
}
// Check for VEX justification context
if (textUntilPosition.match(/justification\s*(==|!=|in)\s*["[]?$/)) {
suggestions.push(...vexJustificationCompletions.map(c => ({ ...c, range })));
}
// Check for action context (after 'then' or 'else')
if (textUntilPosition.match(/\b(then|else)\s*$/)) {
suggestions.push(...actionCompletions.map(c => ({ ...c, range })));
}
// Default: provide all completions
if (suggestions.length === 0) {
suggestions.push(
...keywordCompletions.map(c => ({ ...c, range })),
...functionCompletions.map(c => ({ ...c, range })),
...vexFunctionCompletions.map(c => ({ ...c, range })),
...namespaceCompletions.map(c => ({ ...c, range })),
...actionCompletions.map(c => ({ ...c, range }))
);
}
return { suggestions };
},
});
}

View File

@@ -0,0 +1,367 @@
/**
* Monaco Editor language definition for Stella Policy DSL (`stella-dsl@1`).
*
* This provides syntax highlighting, bracket matching, and folding support
* for the Stella policy language used by the Policy Engine.
*
* @see docs/policy/dsl.md for grammar specification
* @task UI-POLICY-20-001
*/
import type * as Monaco from 'monaco-editor';
export const STELLA_DSL_LANGUAGE_ID = 'stella-dsl';
/**
* Monarch tokenizer configuration for stella-dsl.
* Provides syntax highlighting based on the DSL grammar.
*/
export const stellaDslMonarchLanguage: Monaco.languages.IMonarchLanguage = {
defaultToken: 'invalid',
tokenPostfix: '.stella',
// DSL keywords from grammar
keywords: [
'policy',
'syntax',
'metadata',
'profile',
'settings',
'rule',
'helper',
'map',
'env',
'when',
'then',
'else',
'because',
'priority',
'and',
'or',
'not',
'in',
'source',
],
// Action keywords
actionKeywords: [
'ignore',
'escalate',
'require',
'requireVex',
'warn',
'defer',
'annotate',
'until',
'to',
'message',
],
// Built-in functions
builtinFunctions: [
'normalize_cvss',
'cvss',
'severity_band',
'risk_score',
'reach_state',
'exists',
'coalesce',
'days_between',
'percent_of',
'lowercase',
],
// Namespace identifiers
namespaces: [
'sbom',
'advisory',
'vex',
'run',
'env',
'telemetry',
'signals',
'secret',
'profile',
],
// VEX-related constants
vexStatuses: [
'affected',
'not_affected',
'fixed',
'suppressed',
'under_investigation',
'escalated',
],
vexJustifications: [
'component_not_present',
'vulnerable_code_not_present',
'vulnerable_code_not_in_execute_path',
'vulnerable_code_cannot_be_controlled_by_adversary',
'inline_mitigations_already_exist',
],
// Severity levels
severityLevels: ['critical', 'high', 'medium', 'low', 'none', 'unknown'],
// Reachability states
reachabilityStates: ['reachable', 'unreachable', 'unknown'],
// Operators
operators: [
'=',
':=',
'=>',
'==',
'!=',
'<',
'<=',
'>',
'>=',
],
// Symbol patterns
symbols: /[=><!~?:&|+\-*\/\^%]+/,
// Escape sequences
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
// Tokenizer rules
tokenizer: {
root: [
// Whitespace
{ include: '@whitespace' },
// Comments
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@comment'],
// Strings
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/"/, 'string', '@string'],
// Numbers
[/\d+%/, 'number.percentage'],
[/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'],
[/\d+/, 'number'],
// Booleans
[/\b(true|false)\b/, 'constant.language.boolean'],
// Policy declaration
[/\b(policy)\b/, 'keyword.declaration'],
[/\b(syntax)\b/, 'keyword.declaration'],
// Section keywords
[/\b(metadata|profile|settings|rule|helper)\b/, 'keyword.section'],
// Conditional keywords
[/\b(when|then|else|because)\b/, 'keyword.control'],
// Logical operators
[/\b(and|or|not|in)\b/, 'keyword.operator'],
// Action keywords
[
/\b(ignore|escalate|requireVex|require|warn|defer|annotate|until|to|message)\b/,
'keyword.action',
],
// Priority keyword
[/\b(priority)\b/, 'keyword.modifier'],
// Map/env keywords
[/\b(map|env|source)\b/, 'keyword.declaration'],
// Built-in functions
[
/\b(normalize_cvss|cvss|severity_band|risk_score|reach_state|exists|coalesce|days_between|percent_of|lowercase)\b/,
'support.function',
],
// VEX helper functions
[/\b(vex)\.(any|all|latest|count)\b/, 'support.function.vex'],
// Advisory helper functions
[/\b(advisory)\.(has_tag|matches)\b/, 'support.function.advisory'],
// SBOM helper functions
[/\b(sbom)\.(has_tag|any_component)\b/, 'support.function.sbom'],
// Secret helper functions
[
/\b(secret)\.(hasFinding|match\.count|bundle\.version|mask\.applied|path\.allowlist)\b/,
'support.function.secret',
],
// Ruby scope helpers
[
/\b(ruby)\.(group|groups|declared_only|source|capability|capability_any)\b/,
'support.function.ruby',
],
// Namespace identifiers
[
/\b(sbom|advisory|vex|run|env|telemetry|signals|secret|profile)\b/,
'variable.namespace',
],
// VEX status constants
[
/\b(affected|not_affected|fixed|suppressed|under_investigation|escalated)\b/,
'constant.language.vex-status',
],
// Severity levels
[
/\b(critical|high|medium|low|none)\b(?=\s*["\)])/,
'constant.language.severity',
],
// Reachability states
[
/\b(reachable|unreachable|unknown)\b(?=\s*["\)])/,
'constant.language.reachability',
],
// Delimiters and operators
[/[{}()\[\]]/, '@brackets'],
[/[;,.]/, 'delimiter'],
[/=>/, 'operator.arrow'],
[/:=/, 'operator.assignment'],
[/[=><!~?:&|+\-*\/\^%]+/, 'operator'],
// Identifiers
[/[a-zA-Z_]\w*/, 'identifier'],
],
whitespace: [[/\s+/, 'white']],
comment: [
[/[^\/*]+/, 'comment'],
[/\*\//, 'comment', '@pop'],
[/[\/*]/, 'comment'],
],
string: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop'],
],
},
};
/**
* Language configuration for bracket matching, auto-closing, and folding.
*/
export const stellaDslLanguageConfiguration: Monaco.languages.LanguageConfiguration =
{
comments: {
lineComment: '//',
blockComment: ['/*', '*/'],
},
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"', notIn: ['string'] },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
],
folding: {
markers: {
start: /^\s*(policy|rule|profile|metadata|settings|map|env)\b/,
end: /^\s*\}/,
},
},
indentationRules: {
increaseIndentPattern: /^\s*(policy|rule|profile|metadata|settings|map|env|when|then|else)\b.*\{[^}]*$/,
decreaseIndentPattern: /^\s*\}/,
},
};
/**
* Theme contribution for stella-dsl tokens.
* These rules can be merged into an existing Monaco theme.
*/
export const stellaDslThemeRules: Monaco.editor.ITokenThemeRule[] = [
{ token: 'keyword.declaration.stella', foreground: '569CD6', fontStyle: 'bold' },
{ token: 'keyword.section.stella', foreground: 'C586C0' },
{ token: 'keyword.control.stella', foreground: 'C586C0' },
{ token: 'keyword.operator.stella', foreground: '569CD6' },
{ token: 'keyword.action.stella', foreground: 'DCDCAA' },
{ token: 'keyword.modifier.stella', foreground: '4EC9B0' },
{ token: 'support.function.stella', foreground: 'DCDCAA' },
{ token: 'support.function.vex.stella', foreground: 'DCDCAA' },
{ token: 'support.function.advisory.stella', foreground: 'DCDCAA' },
{ token: 'support.function.sbom.stella', foreground: 'DCDCAA' },
{ token: 'support.function.secret.stella', foreground: 'DCDCAA' },
{ token: 'support.function.ruby.stella', foreground: 'DCDCAA' },
{ token: 'variable.namespace.stella', foreground: '9CDCFE' },
{ token: 'constant.language.boolean.stella', foreground: '569CD6' },
{ token: 'constant.language.vex-status.stella', foreground: '4EC9B0' },
{ token: 'constant.language.severity.stella', foreground: 'CE9178' },
{ token: 'constant.language.reachability.stella', foreground: '4EC9B0' },
{ token: 'number.percentage.stella', foreground: 'B5CEA8' },
{ token: 'operator.arrow.stella', foreground: 'D4D4D4' },
{ token: 'operator.assignment.stella', foreground: 'D4D4D4' },
];
/**
* Registers the stella-dsl language with Monaco editor.
* Call this once during application initialization.
*
* @param monaco - Monaco editor namespace
*/
export function registerStellaDslLanguage(monaco: typeof Monaco): void {
// Register the language
monaco.languages.register({
id: STELLA_DSL_LANGUAGE_ID,
extensions: ['.stella'],
aliases: ['Stella DSL', 'stella-dsl', 'stella'],
mimetypes: ['text/x-stella-dsl'],
});
// Set the tokenizer
monaco.languages.setMonarchTokensProvider(
STELLA_DSL_LANGUAGE_ID,
stellaDslMonarchLanguage
);
// Set the language configuration
monaco.languages.setLanguageConfiguration(
STELLA_DSL_LANGUAGE_ID,
stellaDslLanguageConfiguration
);
}
/**
* Applies stella-dsl theme rules to an existing Monaco theme.
*
* @param monaco - Monaco editor namespace
* @param themeName - Name of the theme to extend
* @param baseTheme - Base theme to inherit from
*/
export function defineStellaDslTheme(
monaco: typeof Monaco,
themeName: string = 'stella-dsl-dark',
baseTheme: Monaco.editor.BuiltinTheme = 'vs-dark'
): void {
monaco.editor.defineTheme(themeName, {
base: baseTheme,
inherit: true,
rules: stellaDslThemeRules,
colors: {},
});
}

View File

@@ -0,0 +1,19 @@
/**
* Policy Studio feature module exports.
*
* This module provides:
* - Monaco editor language definition for stella-dsl
* - Policy API client service
* - Domain models for policies, simulations, and approvals
*
* @task UI-POLICY-20-001, UI-POLICY-20-002, UI-POLICY-20-003, UI-POLICY-20-004
*/
// Editor (Monaco language definition)
export * from './editor';
// Models
export * from './models';
// Services
export * from './services';

View File

@@ -0,0 +1,5 @@
/**
* Policy Studio models exports.
*/
export * from './policy.models';

View File

@@ -0,0 +1,363 @@
/**
* Policy Studio domain models.
*
* Models for policy packs, versions, simulations, and approval workflows.
*
* @task UI-POLICY-20-001, UI-POLICY-20-002, UI-POLICY-20-003
*/
/**
* Policy pack summary for list views.
*/
export interface PolicyPackSummary {
readonly id: string;
readonly name: string;
readonly description: string;
readonly version: string;
readonly status: PolicyPackStatus;
readonly createdAt: string;
readonly modifiedAt: string;
readonly createdBy: string;
readonly modifiedBy: string;
readonly tags: readonly string[];
}
/**
* Full policy pack with content.
*/
export interface PolicyPack {
readonly id: string;
readonly name: string;
readonly description: string;
readonly syntax: string;
readonly content: string;
readonly version: string;
readonly status: PolicyPackStatus;
readonly metadata: PolicyMetadata;
readonly createdAt: string;
readonly modifiedAt: string;
readonly createdBy: string;
readonly modifiedBy: string;
readonly tags: readonly string[];
readonly digest: string;
}
/**
* Policy pack status.
*/
export type PolicyPackStatus =
| 'draft'
| 'pending_review'
| 'in_review'
| 'approved'
| 'rejected'
| 'active'
| 'shadow'
| 'deprecated';
/**
* Policy metadata block.
*/
export interface PolicyMetadata {
readonly description?: string;
readonly tags?: readonly string[];
readonly author?: string;
readonly reviewers?: readonly string[];
}
/**
* Policy version history entry.
*/
export interface PolicyVersion {
readonly version: string;
readonly digest: string;
readonly status: PolicyPackStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly changeDescription: string;
readonly isCurrent: boolean;
}
/**
* Policy lint result.
*/
export interface PolicyLintResult {
readonly valid: boolean;
readonly errors: readonly PolicyDiagnostic[];
readonly warnings: readonly PolicyDiagnostic[];
readonly info: readonly PolicyDiagnostic[];
}
/**
* Policy diagnostic (error, warning, or info).
*/
export interface PolicyDiagnostic {
readonly severity: 'error' | 'warning' | 'info';
readonly code: string;
readonly message: string;
readonly line: number;
readonly column: number;
readonly endLine?: number;
readonly endColumn?: number;
readonly source: string;
}
/**
* Policy compilation result.
*/
export interface PolicyCompilationResult {
readonly success: boolean;
readonly digest?: string;
readonly irPath?: string;
readonly diagnostics: readonly PolicyDiagnostic[];
}
/**
* Simulation request parameters.
*/
export interface SimulationRequest {
readonly policyId: string;
readonly policyVersion?: string;
readonly scope: SimulationScope;
readonly inputs: SimulationInputs;
readonly options?: SimulationOptions;
}
/**
* Simulation scope definition.
*/
export interface SimulationScope {
readonly components?: readonly string[];
readonly advisories?: readonly string[];
readonly sbomId?: string;
readonly environment?: string;
}
/**
* Simulation input data.
*/
export interface SimulationInputs {
readonly env?: Record<string, string>;
readonly signals?: SimulationSignals;
}
/**
* Simulation signals (trust, reachability, etc.).
*/
export interface SimulationSignals {
readonly trust_score?: number;
readonly reachability?: {
readonly state: 'reachable' | 'unreachable' | 'unknown';
readonly score: number;
};
readonly entropy_penalty?: number;
readonly uncertainty?: {
readonly level: 'U1' | 'U2' | 'U3';
};
}
/**
* Simulation options.
*/
export interface SimulationOptions {
readonly includeExplainTrace?: boolean;
readonly diffAgainstActive?: boolean;
readonly sealed?: boolean;
}
/**
* Simulation result.
*/
export interface SimulationResult {
readonly runId: string;
readonly policyId: string;
readonly policyVersion: string;
readonly status: 'completed' | 'failed' | 'timeout';
readonly summary: SimulationSummary;
readonly findings: readonly SimulatedFinding[];
readonly diff?: SimulationDiff;
readonly explainTrace?: readonly ExplainEntry[];
readonly executedAt: string;
readonly durationMs: number;
}
/**
* Simulation summary statistics.
*/
export interface SimulationSummary {
readonly totalFindings: number;
readonly byStatus: Record<string, number>;
readonly bySeverity: Record<string, number>;
readonly ruleHits: readonly RuleHitSummary[];
readonly vexWins: number;
readonly suppressions: number;
}
/**
* Rule hit summary.
*/
export interface RuleHitSummary {
readonly ruleName: string;
readonly hitCount: number;
readonly priority: number;
}
/**
* Simulated finding.
*/
export interface SimulatedFinding {
readonly componentPurl: string;
readonly advisoryId: string;
readonly status: string;
readonly severity: SeverityInfo;
readonly matchedRules: readonly string[];
readonly annotations: Record<string, string>;
}
/**
* Severity information.
*/
export interface SeverityInfo {
readonly band: 'critical' | 'high' | 'medium' | 'low' | 'none';
readonly score?: number;
readonly vector?: string;
}
/**
* Simulation diff against active policy.
*/
export interface SimulationDiff {
readonly added: readonly FindingChange[];
readonly removed: readonly FindingChange[];
readonly changed: readonly FindingChange[];
readonly statusDeltas: Record<string, number>;
readonly severityDeltas: Record<string, number>;
}
/**
* Finding change in diff.
*/
export interface FindingChange {
readonly componentPurl: string;
readonly advisoryId: string;
readonly before?: {
readonly status: string;
readonly severity: SeverityInfo;
};
readonly after?: {
readonly status: string;
readonly severity: SeverityInfo;
};
readonly reason: string;
}
/**
* Explain trace entry.
*/
export interface ExplainEntry {
readonly step: number;
readonly ruleName: string;
readonly priority: number;
readonly matched: boolean;
readonly inputs: Record<string, unknown>;
readonly outputs: Record<string, unknown>;
readonly rationale?: string;
}
/**
* Approval workflow state.
*/
export interface ApprovalWorkflow {
readonly policyId: string;
readonly policyVersion: string;
readonly status: ApprovalStatus;
readonly submittedAt: string;
readonly submittedBy: string;
readonly reviews: readonly ApprovalReview[];
readonly requiredApprovers: number;
readonly currentApprovers: number;
}
/**
* Approval status.
*/
export type ApprovalStatus =
| 'pending'
| 'in_review'
| 'approved'
| 'rejected'
| 'changes_requested';
/**
* Approval review entry.
*/
export interface ApprovalReview {
readonly reviewerId: string;
readonly reviewerName: string;
readonly decision: 'approve' | 'reject' | 'request_changes';
readonly comment: string;
readonly reviewedAt: string;
}
/**
* Policy run dashboard data.
*/
export interface PolicyRunDashboard {
readonly policyId: string;
readonly runs: readonly PolicyRunSummary[];
readonly ruleHeatmap: readonly RuleHeatmapEntry[];
readonly vexWinsByDay: readonly TimeSeriesEntry[];
readonly suppressionsByDay: readonly TimeSeriesEntry[];
}
/**
* Policy run summary.
*/
export interface PolicyRunSummary {
readonly runId: string;
readonly policyVersion: string;
readonly startedAt: string;
readonly completedAt: string;
readonly status: 'completed' | 'failed' | 'timeout';
readonly findingsCount: number;
readonly changedCount: number;
}
/**
* Rule heatmap entry.
*/
export interface RuleHeatmapEntry {
readonly ruleName: string;
readonly hitCount: number;
readonly lastHit: string;
readonly averageLatencyMs: number;
}
/**
* Time series data entry.
*/
export interface TimeSeriesEntry {
readonly date: string;
readonly value: number;
}
/**
* Policy submission request.
*/
export interface PolicySubmissionRequest {
readonly policyId: string;
readonly version: string;
readonly message: string;
readonly coverageResults?: string;
readonly simulationDiff?: string;
}
/**
* Policy promotion request.
*/
export interface PolicyPromotionRequest {
readonly policyId: string;
readonly version: string;
readonly targetEnvironment: string;
readonly reason: string;
}

View File

@@ -0,0 +1,5 @@
/**
* Policy Studio services exports.
*/
export { PolicyApiService } from './policy-api.service';

View File

@@ -0,0 +1,375 @@
/**
* Policy API client service.
*
* Provides methods for interacting with the Policy Gateway API:
* - Pack CRUD operations
* - Lint and compile
* - Simulation
* - Approval workflow
* - Run dashboards
*
* @task UI-POLICY-20-001, UI-POLICY-20-002, UI-POLICY-20-003, UI-POLICY-20-004
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import type {
PolicyPackSummary,
PolicyPack,
PolicyVersion,
PolicyLintResult,
PolicyCompilationResult,
SimulationRequest,
SimulationResult,
ApprovalWorkflow,
ApprovalReview,
PolicyRunDashboard,
PolicySubmissionRequest,
PolicyPromotionRequest,
} from '../models/policy.models';
/**
* Policy API base path.
*/
const API_BASE = '/api/policy';
/**
* Policy API client service.
*/
@Injectable({ providedIn: 'root' })
export class PolicyApiService {
private readonly http = inject(HttpClient);
// ============================================================================
// Pack Management
// ============================================================================
/**
* List all policy packs.
*
* @param params - Optional filter parameters
*/
listPacks(params?: {
status?: string;
tag?: string;
search?: string;
limit?: number;
offset?: number;
}): Observable<PolicyPackSummary[]> {
let httpParams = new HttpParams();
if (params?.status) httpParams = httpParams.set('status', params.status);
if (params?.tag) httpParams = httpParams.set('tag', params.tag);
if (params?.search) httpParams = httpParams.set('search', params.search);
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());
if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString());
return this.http.get<PolicyPackSummary[]>(`${API_BASE}/packs`, { params: httpParams });
}
/**
* Get a single policy pack by ID.
*
* @param packId - Policy pack ID
* @param version - Optional specific version
*/
getPack(packId: string, version?: string): Observable<PolicyPack> {
let httpParams = new HttpParams();
if (version) httpParams = httpParams.set('version', version);
return this.http.get<PolicyPack>(`${API_BASE}/packs/${packId}`, { params: httpParams });
}
/**
* Create a new policy pack.
*
* @param pack - Policy pack data
*/
createPack(pack: {
name: string;
description: string;
content: string;
tags?: string[];
}): Observable<PolicyPack> {
return this.http.post<PolicyPack>(`${API_BASE}/packs`, pack);
}
/**
* Update an existing policy pack.
*
* @param packId - Policy pack ID
* @param pack - Updated policy pack data
*/
updatePack(
packId: string,
pack: {
name?: string;
description?: string;
content?: string;
tags?: string[];
}
): Observable<PolicyPack> {
return this.http.put<PolicyPack>(`${API_BASE}/packs/${packId}`, pack);
}
/**
* Delete a policy pack.
*
* @param packId - Policy pack ID
*/
deletePack(packId: string): Observable<void> {
return this.http.delete<void>(`${API_BASE}/packs/${packId}`);
}
/**
* Get version history for a policy pack.
*
* @param packId - Policy pack ID
*/
getVersionHistory(packId: string): Observable<PolicyVersion[]> {
return this.http.get<PolicyVersion[]>(`${API_BASE}/packs/${packId}/versions`);
}
/**
* Restore a previous version of a policy pack.
*
* @param packId - Policy pack ID
* @param version - Version to restore
*/
restoreVersion(packId: string, version: string): Observable<PolicyPack> {
return this.http.post<PolicyPack>(`${API_BASE}/packs/${packId}/versions/${version}/restore`, {});
}
// ============================================================================
// Lint and Compile
// ============================================================================
/**
* Lint policy content.
*
* @param content - Policy DSL content to lint
*/
lint(content: string): Observable<PolicyLintResult> {
return this.http.post<PolicyLintResult>(`${API_BASE}/lint`, { content });
}
/**
* Compile policy content.
*
* @param packId - Policy pack ID
* @param options - Compile options
*/
compile(
packId: string,
options?: { version?: string; includeIr?: boolean }
): Observable<PolicyCompilationResult> {
let httpParams = new HttpParams();
if (options?.version) httpParams = httpParams.set('version', options.version);
if (options?.includeIr) httpParams = httpParams.set('includeIr', 'true');
return this.http.post<PolicyCompilationResult>(
`${API_BASE}/packs/${packId}/compile`,
{},
{ params: httpParams }
);
}
// ============================================================================
// Simulation
// ============================================================================
/**
* Run a policy simulation.
*
* @param request - Simulation request
*/
simulate(request: SimulationRequest): Observable<SimulationResult> {
return this.http.post<SimulationResult>(`${API_BASE}/simulate`, request);
}
/**
* Get a simulation result by run ID.
*
* @param runId - Simulation run ID
*/
getSimulationResult(runId: string): Observable<SimulationResult> {
return this.http.get<SimulationResult>(`${API_BASE}/simulations/${runId}`);
}
/**
* List recent simulations for a policy.
*
* @param packId - Policy pack ID
* @param limit - Maximum results to return
*/
listSimulations(packId: string, limit: number = 10): Observable<SimulationResult[]> {
return this.http.get<SimulationResult[]>(
`${API_BASE}/packs/${packId}/simulations`,
{ params: new HttpParams().set('limit', limit.toString()) }
);
}
// ============================================================================
// Approval Workflow
// ============================================================================
/**
* Submit a policy for review.
*
* @param request - Submission request
*/
submitForReview(request: PolicySubmissionRequest): Observable<ApprovalWorkflow> {
return this.http.post<ApprovalWorkflow>(
`${API_BASE}/packs/${request.policyId}/submit`,
request
);
}
/**
* Get the current approval workflow state.
*
* @param packId - Policy pack ID
* @param version - Policy version
*/
getApprovalWorkflow(packId: string, version: string): Observable<ApprovalWorkflow> {
return this.http.get<ApprovalWorkflow>(
`${API_BASE}/packs/${packId}/versions/${version}/approval`
);
}
/**
* Add a review to the approval workflow.
*
* @param packId - Policy pack ID
* @param version - Policy version
* @param review - Review data
*/
addReview(
packId: string,
version: string,
review: {
decision: 'approve' | 'reject' | 'request_changes';
comment: string;
}
): Observable<ApprovalReview> {
return this.http.post<ApprovalReview>(
`${API_BASE}/packs/${packId}/versions/${version}/reviews`,
review
);
}
/**
* Promote a policy to a target environment.
* Requires interactive authentication (policy:promote scope).
*
* @param request - Promotion request
*/
promote(request: PolicyPromotionRequest): Observable<{ success: boolean; promotedAt: string }> {
return this.http.post<{ success: boolean; promotedAt: string }>(
`${API_BASE}/packs/${request.policyId}/promote`,
request
);
}
/**
* Activate a policy (switch from shadow to active mode).
*
* @param packId - Policy pack ID
* @param version - Policy version
*/
activate(packId: string, version: string): Observable<PolicyPack> {
return this.http.post<PolicyPack>(
`${API_BASE}/packs/${packId}/versions/${version}/activate`,
{}
);
}
/**
* Deprecate a policy pack.
*
* @param packId - Policy pack ID
* @param reason - Deprecation reason
*/
deprecate(packId: string, reason: string): Observable<PolicyPack> {
return this.http.post<PolicyPack>(
`${API_BASE}/packs/${packId}/deprecate`,
{ reason }
);
}
// ============================================================================
// Run Dashboards
// ============================================================================
/**
* Get policy run dashboard data.
*
* @param packId - Policy pack ID
* @param options - Dashboard options
*/
getRunDashboard(
packId: string,
options?: {
startDate?: string;
endDate?: string;
limit?: number;
}
): Observable<PolicyRunDashboard> {
let httpParams = new HttpParams();
if (options?.startDate) httpParams = httpParams.set('startDate', options.startDate);
if (options?.endDate) httpParams = httpParams.set('endDate', options.endDate);
if (options?.limit) httpParams = httpParams.set('limit', options.limit.toString());
return this.http.get<PolicyRunDashboard>(
`${API_BASE}/packs/${packId}/dashboard`,
{ params: httpParams }
);
}
/**
* Get rule heatmap data for a policy.
*
* @param packId - Policy pack ID
* @param days - Number of days to include (default 30)
*/
getRuleHeatmap(packId: string, days: number = 30): Observable<{ rules: Array<{
ruleName: string;
hitsByDay: Array<{ date: string; count: number }>;
}> }> {
return this.http.get<{ rules: Array<{
ruleName: string;
hitsByDay: Array<{ date: string; count: number }>;
}> }>(
`${API_BASE}/packs/${packId}/heatmap`,
{ params: new HttpParams().set('days', days.toString()) }
);
}
/**
* Export policy run results.
*
* @param packId - Policy pack ID
* @param format - Export format
* @param options - Export options
*/
exportResults(
packId: string,
format: 'json' | 'csv' | 'pdf',
options?: {
startDate?: string;
endDate?: string;
includeExplain?: boolean;
}
): Observable<Blob> {
let httpParams = new HttpParams().set('format', format);
if (options?.startDate) httpParams = httpParams.set('startDate', options.startDate);
if (options?.endDate) httpParams = httpParams.set('endDate', options.endDate);
if (options?.includeExplain) httpParams = httpParams.set('includeExplain', 'true');
return this.http.get(`${API_BASE}/packs/${packId}/export`, {
params: httpParams,
responseType: 'blob',
});
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.CodeAnalysis;
namespace StellaOps.Microservice.SourceGen;
/// <summary>
/// Diagnostic descriptors for the source generator.
/// </summary>
internal static class DiagnosticDescriptors
{
private const string Category = "StellaOps.Microservice";
/// <summary>
/// Class with [StellaEndpoint] must implement IStellaEndpoint or IRawStellaEndpoint.
/// </summary>
public static readonly DiagnosticDescriptor MissingHandlerInterface = new(
id: "STELLA001",
title: "Missing handler interface",
messageFormat: "Class '{0}' with [StellaEndpoint] must implement IStellaEndpoint<TRequest, TResponse> or IRawStellaEndpoint",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
/// <summary>
/// Duplicate endpoint detected.
/// </summary>
public static readonly DiagnosticDescriptor DuplicateEndpoint = new(
id: "STELLA002",
title: "Duplicate endpoint",
messageFormat: "Duplicate endpoint: {0} {1} is defined in both '{2}' and '{3}'",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
/// <summary>
/// [StellaEndpoint] on abstract class is ignored.
/// </summary>
public static readonly DiagnosticDescriptor AbstractClassIgnored = new(
id: "STELLA003",
title: "Abstract class ignored",
messageFormat: "[StellaEndpoint] on abstract class '{0}' is ignored",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
/// <summary>
/// Informational: endpoints generated.
/// </summary>
public static readonly DiagnosticDescriptor EndpointsGenerated = new(
id: "STELLA004",
title: "Endpoints generated",
messageFormat: "Generated {0} endpoint descriptors",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: false);
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Microservice.SourceGen;
/// <summary>
/// Holds extracted endpoint information from a [StellaEndpoint] decorated class.
/// </summary>
internal sealed record EndpointInfo(
string Namespace,
string ClassName,
string FullyQualifiedName,
string Method,
string Path,
int TimeoutSeconds,
bool SupportsStreaming,
string[] RequiredClaims,
string? RequestTypeName,
string? ResponseTypeName,
bool IsRaw);

View File

@@ -1,13 +0,0 @@
namespace StellaOps.Microservice.SourceGen;
/// <summary>
/// Placeholder type for the source generator project.
/// This will be replaced with actual source generator implementation in a later sprint.
/// </summary>
public static class Placeholder
{
/// <summary>
/// Indicates the source generator is not yet implemented.
/// </summary>
public const string Status = "NotImplemented";
}

View File

@@ -0,0 +1,10 @@
// Polyfills for netstandard2.0 compatibility
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices
{
/// <summary>
/// Allows use of init accessors in netstandard2.0.
/// </summary>
internal static class IsExternalInit { }
}

View File

@@ -0,0 +1,399 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace StellaOps.Microservice.SourceGen;
/// <summary>
/// Incremental source generator for [StellaEndpoint] decorated classes.
/// Generates endpoint descriptors and DI registration at compile time.
/// </summary>
[Generator]
public sealed class StellaEndpointGenerator : IIncrementalGenerator
{
private const string StellaEndpointAttributeName = "StellaOps.Microservice.StellaEndpointAttribute";
private const string IStellaEndpointName = "StellaOps.Microservice.IStellaEndpoint";
private const string IRawStellaEndpointName = "StellaOps.Microservice.IRawStellaEndpoint";
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all class declarations with attributes
var classDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsSyntaxTargetForGeneration(s),
transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx))
.Where(static m => m is not null);
// Combine all endpoints and generate
var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations.Collect());
context.RegisterSourceOutput(
compilationAndClasses,
static (spc, source) => Execute(source.Left, source.Right!, spc));
}
private static bool IsSyntaxTargetForGeneration(SyntaxNode node)
{
return node is ClassDeclarationSyntax { AttributeLists.Count: > 0 } classDecl
&& !classDecl.Modifiers.Any(SyntaxKind.AbstractKeyword);
}
private static ClassDeclarationSyntax? GetSemanticTargetForGeneration(GeneratorSyntaxContext context)
{
var classDeclaration = (ClassDeclarationSyntax)context.Node;
foreach (var attributeList in classDeclaration.AttributeLists)
{
foreach (var attribute in attributeList.Attributes)
{
var symbolInfo = context.SemanticModel.GetSymbolInfo(attribute);
var symbol = symbolInfo.Symbol;
if (symbol is not IMethodSymbol attributeSymbol)
continue;
var attributeContainingType = attributeSymbol.ContainingType;
var fullName = attributeContainingType.ToDisplayString();
if (fullName == StellaEndpointAttributeName)
{
return classDeclaration;
}
}
}
return null;
}
private static void Execute(
Compilation compilation,
ImmutableArray<ClassDeclarationSyntax?> classes,
SourceProductionContext context)
{
if (classes.IsDefaultOrEmpty)
return;
var distinctClasses = classes.Where(c => c is not null).Distinct().Cast<ClassDeclarationSyntax>();
var endpoints = new List<EndpointInfo>();
foreach (var classDeclaration in distinctClasses)
{
var semanticModel = compilation.GetSemanticModel(classDeclaration.SyntaxTree);
var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration);
if (classSymbol is null)
continue;
var endpoint = ExtractEndpointInfo(classSymbol, context);
if (endpoint is not null)
{
endpoints.Add(endpoint);
}
}
if (endpoints.Count == 0)
return;
// Check for duplicates
var seen = new Dictionary<(string Method, string Path), EndpointInfo>();
foreach (var endpoint in endpoints)
{
var key = (endpoint.Method, endpoint.Path);
if (seen.TryGetValue(key, out var existing))
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.DuplicateEndpoint,
Location.None,
endpoint.Method,
endpoint.Path,
existing.ClassName,
endpoint.ClassName));
}
else
{
seen[key] = endpoint;
}
}
// Generate the source
var source = GenerateEndpointsClass(endpoints);
context.AddSource("StellaEndpoints.g.cs", SourceText.From(source, Encoding.UTF8));
// Generate the provider class
var providerSource = GenerateProviderClass();
context.AddSource("GeneratedEndpointProvider.g.cs", SourceText.From(providerSource, Encoding.UTF8));
}
private static EndpointInfo? ExtractEndpointInfo(INamedTypeSymbol classSymbol, SourceProductionContext context)
{
// Find StellaEndpoint attribute
AttributeData? stellaAttribute = null;
foreach (var attr in classSymbol.GetAttributes())
{
if (attr.AttributeClass?.ToDisplayString() == StellaEndpointAttributeName)
{
stellaAttribute = attr;
break;
}
}
if (stellaAttribute is null)
return null;
// Check for abstract class
if (classSymbol.IsAbstract)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.AbstractClassIgnored,
Location.None,
classSymbol.Name));
return null;
}
// Extract constructor arguments: method and path
if (stellaAttribute.ConstructorArguments.Length < 2)
return null;
var method = stellaAttribute.ConstructorArguments[0].Value as string ?? "GET";
var path = stellaAttribute.ConstructorArguments[1].Value as string ?? "/";
// Extract named arguments
var timeoutSeconds = 30;
var supportsStreaming = false;
var requiredClaims = Array.Empty<string>();
foreach (var namedArg in stellaAttribute.NamedArguments)
{
switch (namedArg.Key)
{
case "TimeoutSeconds":
timeoutSeconds = (int)(namedArg.Value.Value ?? 30);
break;
case "SupportsStreaming":
supportsStreaming = (bool)(namedArg.Value.Value ?? false);
break;
case "RequiredClaims":
if (!namedArg.Value.IsNull && namedArg.Value.Values.Length > 0)
{
requiredClaims = namedArg.Value.Values
.Select(v => v.Value as string)
.Where(s => s is not null)
.Cast<string>()
.ToArray();
}
break;
}
}
// Find handler interface implementation
string? requestTypeName = null;
string? responseTypeName = null;
bool isRaw = false;
foreach (var iface in classSymbol.AllInterfaces)
{
var fullName = iface.OriginalDefinition.ToDisplayString();
if (fullName.StartsWith(IStellaEndpointName) && iface.TypeArguments.Length == 2)
{
requestTypeName = iface.TypeArguments[0].ToDisplayString();
responseTypeName = iface.TypeArguments[1].ToDisplayString();
isRaw = false;
break;
}
if (fullName == IRawStellaEndpointName)
{
isRaw = true;
break;
}
}
// If no handler interface found, report error
if (!isRaw && requestTypeName is null)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.MissingHandlerInterface,
Location.None,
classSymbol.Name));
return null;
}
var ns = classSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: classSymbol.ContainingNamespace.ToDisplayString();
return new EndpointInfo(
Namespace: ns,
ClassName: classSymbol.Name,
FullyQualifiedName: classSymbol.ToDisplayString(),
Method: method.ToUpperInvariant(),
Path: path,
TimeoutSeconds: timeoutSeconds,
SupportsStreaming: supportsStreaming,
RequiredClaims: requiredClaims,
RequestTypeName: requestTypeName,
ResponseTypeName: responseTypeName,
IsRaw: isRaw);
}
private static string GenerateEndpointsClass(List<EndpointInfo> endpoints)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("namespace StellaOps.Microservice.Generated");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated endpoint metadata and registration.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"StellaOps.Microservice.SourceGen\", \"1.0.0\")]");
sb.AppendLine(" internal static class StellaEndpoints");
sb.AppendLine(" {");
// GetEndpoints method
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Gets all discovered endpoint descriptors.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static global::System.Collections.Generic.IReadOnlyList<global::StellaOps.Router.Common.Models.EndpointDescriptor> GetEndpoints()");
sb.AppendLine(" {");
sb.AppendLine(" return new global::StellaOps.Router.Common.Models.EndpointDescriptor[]");
sb.AppendLine(" {");
for (int i = 0; i < endpoints.Count; i++)
{
var ep = endpoints[i];
sb.AppendLine(" new global::StellaOps.Router.Common.Models.EndpointDescriptor");
sb.AppendLine(" {");
sb.AppendLine(" ServiceName = string.Empty, // Set by SDK at registration");
sb.AppendLine(" Version = string.Empty, // Set by SDK at registration");
sb.AppendLine($" Method = \"{EscapeString(ep.Method)}\",");
sb.AppendLine($" Path = \"{EscapeString(ep.Path)}\",");
sb.AppendLine($" DefaultTimeout = global::System.TimeSpan.FromSeconds({ep.TimeoutSeconds}),");
sb.AppendLine($" SupportsStreaming = {(ep.SupportsStreaming ? "true" : "false")},");
sb.Append(" RequiringClaims = ");
if (ep.RequiredClaims.Length == 0)
{
sb.AppendLine("new global::System.Collections.Generic.List<global::StellaOps.Router.Common.Models.ClaimRequirement>(),");
}
else
{
sb.AppendLine("new global::System.Collections.Generic.List<global::StellaOps.Router.Common.Models.ClaimRequirement>");
sb.AppendLine(" {");
foreach (var claim in ep.RequiredClaims)
{
sb.AppendLine($" new global::StellaOps.Router.Common.Models.ClaimRequirement {{ Type = \"{EscapeString(claim)}\", Value = null }},");
}
sb.AppendLine(" },");
}
sb.AppendLine($" HandlerType = typeof(global::{ep.FullyQualifiedName})");
sb.Append(" }");
if (i < endpoints.Count - 1)
{
sb.AppendLine(",");
}
else
{
sb.AppendLine();
}
}
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine();
// RegisterHandlers method
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers all endpoint handlers with the service collection.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static void RegisterHandlers(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)");
sb.AppendLine(" {");
foreach (var ep in endpoints)
{
sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<global::{ep.FullyQualifiedName}>(services);");
}
sb.AppendLine(" }");
// GetHandlerTypes method
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Gets all handler types for endpoint discovery.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static global::System.Collections.Generic.IReadOnlyList<global::System.Type> GetHandlerTypes()");
sb.AppendLine(" {");
sb.AppendLine(" return new global::System.Type[]");
sb.AppendLine(" {");
for (int i = 0; i < endpoints.Count; i++)
{
var ep = endpoints[i];
sb.Append($" typeof(global::{ep.FullyQualifiedName})");
if (i < endpoints.Count - 1)
{
sb.AppendLine(",");
}
else
{
sb.AppendLine();
}
}
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateProviderClass()
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("namespace StellaOps.Microservice.Generated");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Generated implementation of IGeneratedEndpointProvider.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"StellaOps.Microservice.SourceGen\", \"1.0.0\")]");
sb.AppendLine(" internal sealed class GeneratedEndpointProvider : global::StellaOps.Microservice.IGeneratedEndpointProvider");
sb.AppendLine(" {");
sb.AppendLine(" /// <inheritdoc />");
sb.AppendLine(" public global::System.Collections.Generic.IReadOnlyList<global::StellaOps.Router.Common.Models.EndpointDescriptor> GetEndpoints()");
sb.AppendLine(" => StellaEndpoints.GetEndpoints();");
sb.AppendLine();
sb.AppendLine(" /// <inheritdoc />");
sb.AppendLine(" public void RegisterHandlers(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)");
sb.AppendLine(" => StellaEndpoints.RegisterHandlers(services);");
sb.AppendLine();
sb.AppendLine(" /// <inheritdoc />");
sb.AppendLine(" public global::System.Collections.Generic.IReadOnlyList<global::System.Type> GetHandlerTypes()");
sb.AppendLine(" => StellaEndpoints.GetHandlerTypes();");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string EscapeString(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
}
}

View File

@@ -1,9 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<!-- Source generators must target netstandard2.0 for Roslyn compatibility -->
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Source generator specific settings -->
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<!-- Package settings for distribution -->
<PackageId>StellaOps.Microservice.SourceGen</PackageId>
<Description>Source generator for Stella microservice endpoints</Description>
<DevelopmentDependency>true</DevelopmentDependency>
<IsPackable>true</IsPackable>
<NoWarn>$(NoWarn);NU5128;RS2008</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
</ItemGroup>
<!-- Pack the analyzer as an analyzer -->
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for discovering endpoints with YAML configuration support.
/// </summary>
public interface IEndpointDiscoveryService
{
/// <summary>
/// Discovers all endpoints, applying any YAML configuration overrides.
/// </summary>
/// <returns>The discovered endpoints with overrides applied.</returns>
IReadOnlyList<EndpointDescriptor> DiscoverEndpoints();
}
/// <summary>
/// Service that discovers endpoints and applies YAML configuration overrides.
/// </summary>
public sealed class EndpointDiscoveryService : IEndpointDiscoveryService
{
private readonly IEndpointDiscoveryProvider _discoveryProvider;
private readonly IMicroserviceYamlLoader _yamlLoader;
private readonly IEndpointOverrideMerger _merger;
private readonly ILogger<EndpointDiscoveryService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointDiscoveryService"/> class.
/// </summary>
public EndpointDiscoveryService(
IEndpointDiscoveryProvider discoveryProvider,
IMicroserviceYamlLoader yamlLoader,
IEndpointOverrideMerger merger,
ILogger<EndpointDiscoveryService> logger)
{
_discoveryProvider = discoveryProvider;
_yamlLoader = yamlLoader;
_merger = merger;
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
// 1. Discover endpoints from code (via reflection or source gen)
var codeEndpoints = _discoveryProvider.DiscoverEndpoints();
_logger.LogDebug("Discovered {Count} endpoints from code", codeEndpoints.Count);
// 2. Load YAML overrides
MicroserviceYamlConfig? yamlConfig = null;
try
{
yamlConfig = _yamlLoader.Load();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load YAML configuration, using code defaults only");
}
// 3. Merge code endpoints with YAML overrides
var mergedEndpoints = _merger.Merge(codeEndpoints, yamlConfig);
_logger.LogInformation(
"Endpoint discovery complete: {Count} endpoints (YAML overrides: {HasYaml})",
mergedEndpoints.Count,
yamlConfig != null);
return mergedEndpoints;
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for merging endpoint overrides from YAML configuration.
/// </summary>
public interface IEndpointOverrideMerger
{
/// <summary>
/// Merges YAML overrides with code-defined endpoints.
/// </summary>
/// <param name="codeEndpoints">The endpoints discovered from code.</param>
/// <param name="yamlConfig">The YAML configuration, if any.</param>
/// <returns>The merged endpoints.</returns>
IReadOnlyList<EndpointDescriptor> Merge(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig? yamlConfig);
}
/// <summary>
/// Merges endpoint overrides from YAML configuration with code defaults.
/// </summary>
public sealed class EndpointOverrideMerger : IEndpointOverrideMerger
{
private readonly ILogger<EndpointOverrideMerger> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointOverrideMerger"/> class.
/// </summary>
public EndpointOverrideMerger(ILogger<EndpointOverrideMerger> logger)
{
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> Merge(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig? yamlConfig)
{
if (yamlConfig == null || yamlConfig.Endpoints.Count == 0)
{
return codeEndpoints;
}
WarnUnmatchedOverrides(codeEndpoints, yamlConfig);
return codeEndpoints.Select(ep =>
{
var yamlOverride = FindMatchingOverride(ep, yamlConfig);
return yamlOverride == null ? ep : MergeEndpoint(ep, yamlOverride);
}).ToList();
}
private static EndpointOverrideConfig? FindMatchingOverride(
EndpointDescriptor endpoint,
MicroserviceYamlConfig yamlConfig)
{
return yamlConfig.Endpoints.FirstOrDefault(y =>
string.Equals(y.Method, endpoint.Method, StringComparison.OrdinalIgnoreCase) &&
string.Equals(y.Path, endpoint.Path, StringComparison.OrdinalIgnoreCase));
}
private EndpointDescriptor MergeEndpoint(
EndpointDescriptor codeDefault,
EndpointOverrideConfig yamlOverride)
{
var merged = codeDefault with
{
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
SupportsStreaming = yamlOverride.SupportsStreaming ?? codeDefault.SupportsStreaming,
RequiringClaims = yamlOverride.RequiringClaims?.Count > 0
? yamlOverride.RequiringClaims.Select(c => c.ToClaimRequirement()).ToList()
: codeDefault.RequiringClaims
};
if (yamlOverride.GetDefaultTimeoutAsTimeSpan().HasValue ||
yamlOverride.SupportsStreaming.HasValue ||
yamlOverride.RequiringClaims?.Count > 0)
{
_logger.LogDebug(
"Applied YAML overrides to endpoint {Method} {Path}: Timeout={Timeout}, Streaming={Streaming}, Claims={Claims}",
merged.Method,
merged.Path,
merged.DefaultTimeout,
merged.SupportsStreaming,
merged.RequiringClaims?.Count ?? 0);
}
return merged;
}
private void WarnUnmatchedOverrides(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig yamlConfig)
{
var codeKeys = codeEndpoints
.Select(e => (Method: e.Method.ToUpperInvariant(), Path: e.Path.ToLowerInvariant()))
.ToHashSet();
foreach (var yamlEntry in yamlConfig.Endpoints)
{
var key = (Method: yamlEntry.Method.ToUpperInvariant(), Path: yamlEntry.Path.ToLowerInvariant());
if (!codeKeys.Contains(key))
{
_logger.LogWarning(
"YAML override for {Method} {Path} does not match any code endpoint. " +
"YAML cannot create endpoints, only modify existing ones.",
yamlEntry.Method,
yamlEntry.Path);
}
}
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>

View File

@@ -0,0 +1,110 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Discovers endpoints using source-generated provider, falling back to reflection.
/// </summary>
public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
private readonly StellaMicroserviceOptions _options;
private readonly ILogger<GeneratedEndpointDiscoveryProvider> _logger;
private readonly ReflectionEndpointDiscoveryProvider _reflectionFallback;
private const string GeneratedProviderTypeName = "StellaOps.Microservice.Generated.GeneratedEndpointProvider";
/// <summary>
/// Initializes a new instance of the <see cref="GeneratedEndpointDiscoveryProvider"/> class.
/// </summary>
public GeneratedEndpointDiscoveryProvider(
StellaMicroserviceOptions options,
ILogger<GeneratedEndpointDiscoveryProvider> logger)
{
_options = options;
_logger = logger;
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(options);
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
// Try to find the generated provider
var generatedProvider = TryGetGeneratedProvider();
if (generatedProvider != null)
{
_logger.LogDebug("Using source-generated endpoint discovery");
var endpoints = generatedProvider.GetEndpoints();
// Apply service name and version from options
var result = new List<EndpointDescriptor>();
foreach (var endpoint in endpoints)
{
result.Add(endpoint with
{
ServiceName = _options.ServiceName,
Version = _options.Version
});
}
_logger.LogInformation(
"Discovered {Count} endpoints via source generation",
result.Count);
return result;
}
// Fall back to reflection
_logger.LogDebug("Source-generated provider not found, falling back to reflection");
return _reflectionFallback.DiscoverEndpoints();
}
private IGeneratedEndpointProvider? TryGetGeneratedProvider()
{
try
{
// Look in the entry assembly first
var entryAssembly = Assembly.GetEntryAssembly();
var providerType = entryAssembly?.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
// Also check the calling assembly
var callingAssembly = Assembly.GetCallingAssembly();
providerType = callingAssembly.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
// Check all loaded assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
providerType = assembly.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
}
catch
{
// Ignore assembly loading errors
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load generated endpoint provider");
}
return null;
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface implemented by the source-generated endpoint provider.
/// </summary>
public interface IGeneratedEndpointProvider
{
/// <summary>
/// Gets all discovered endpoint descriptors.
/// </summary>
IReadOnlyList<EndpointDescriptor> GetEndpoints();
/// <summary>
/// Registers all endpoint handlers with the service collection.
/// </summary>
void RegisterHandlers(IServiceCollection services);
/// <summary>
/// Gets all handler types for endpoint discovery.
/// </summary>
IReadOnlyList<Type> GetHandlerTypes();
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace StellaOps.Microservice;
/// <summary>
/// Tracks in-flight requests and manages their cancellation tokens.
/// </summary>
public sealed class InflightRequestTracker : IDisposable
{
private readonly ConcurrentDictionary<Guid, InflightRequest> _inflight = new();
private readonly ILogger<InflightRequestTracker> _logger;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="InflightRequestTracker"/> class.
/// </summary>
public InflightRequestTracker(ILogger<InflightRequestTracker> logger)
{
_logger = logger;
}
/// <summary>
/// Gets the count of in-flight requests.
/// </summary>
public int Count => _inflight.Count;
/// <summary>
/// Starts tracking a request and returns a cancellation token for it.
/// </summary>
/// <param name="correlationId">The correlation ID of the request.</param>
/// <returns>A cancellation token that will be triggered if the request is cancelled.</returns>
public CancellationToken Track(Guid correlationId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var cts = new CancellationTokenSource();
var request = new InflightRequest(cts);
if (!_inflight.TryAdd(correlationId, request))
{
cts.Dispose();
throw new InvalidOperationException($"Request {correlationId} is already being tracked");
}
_logger.LogDebug("Started tracking request {CorrelationId}", correlationId);
return cts.Token;
}
/// <summary>
/// Cancels a specific request.
/// </summary>
/// <param name="correlationId">The correlation ID of the request to cancel.</param>
/// <param name="reason">The reason for cancellation.</param>
/// <returns>True if the request was found and cancelled; otherwise false.</returns>
public bool Cancel(Guid correlationId, string? reason)
{
if (_inflight.TryGetValue(correlationId, out var request))
{
try
{
request.Cts.Cancel();
_logger.LogInformation(
"Cancelled request {CorrelationId}: {Reason}",
correlationId,
reason ?? "Unknown");
return true;
}
catch (ObjectDisposedException)
{
// CTS was already disposed, request completed
return false;
}
}
_logger.LogDebug(
"Cannot cancel request {CorrelationId}: not found (may have already completed)",
correlationId);
return false;
}
/// <summary>
/// Marks a request as completed and removes it from tracking.
/// </summary>
/// <param name="correlationId">The correlation ID of the completed request.</param>
public void Complete(Guid correlationId)
{
if (_inflight.TryRemove(correlationId, out var request))
{
request.Cts.Dispose();
_logger.LogDebug("Completed request {CorrelationId}", correlationId);
}
}
/// <summary>
/// Cancels all in-flight requests.
/// </summary>
/// <param name="reason">The reason for cancellation.</param>
public void CancelAll(string reason)
{
var count = 0;
foreach (var kvp in _inflight)
{
try
{
kvp.Value.Cts.Cancel();
count++;
}
catch (ObjectDisposedException)
{
// Already disposed
}
}
_logger.LogInformation("Cancelled {Count} in-flight requests: {Reason}", count, reason);
// Clear and dispose all
foreach (var kvp in _inflight)
{
if (_inflight.TryRemove(kvp.Key, out var request))
{
request.Cts.Dispose();
}
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
CancelAll("Disposing tracker");
}
private sealed class InflightRequest
{
public CancellationTokenSource Cts { get; }
public InflightRequest(CancellationTokenSource cts)
{
Cts = cts;
}
}
}

View File

@@ -0,0 +1,113 @@
using StellaOps.Router.Common.Models;
using YamlDotNet.Serialization;
namespace StellaOps.Microservice;
/// <summary>
/// Root configuration for microservice endpoint overrides loaded from YAML.
/// </summary>
public sealed class MicroserviceYamlConfig
{
/// <summary>
/// Gets or sets the endpoint override configurations.
/// </summary>
[YamlMember(Alias = "endpoints")]
public List<EndpointOverrideConfig> Endpoints { get; set; } = [];
}
/// <summary>
/// Configuration for overriding an endpoint's properties.
/// </summary>
public sealed class EndpointOverrideConfig
{
/// <summary>
/// Gets or sets the HTTP method to match.
/// </summary>
[YamlMember(Alias = "method")]
public string Method { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to match.
/// </summary>
[YamlMember(Alias = "path")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the default timeout override.
/// </summary>
[YamlMember(Alias = "defaultTimeout")]
public string? DefaultTimeout { get; set; }
/// <summary>
/// Gets or sets whether streaming is supported.
/// </summary>
[YamlMember(Alias = "supportsStreaming")]
public bool? SupportsStreaming { get; set; }
/// <summary>
/// Gets or sets the claim requirements.
/// </summary>
[YamlMember(Alias = "requiringClaims")]
public List<ClaimRequirementConfig>? RequiringClaims { get; set; }
/// <summary>
/// Parses the DefaultTimeout string to a TimeSpan.
/// </summary>
public TimeSpan? GetDefaultTimeoutAsTimeSpan()
{
if (string.IsNullOrWhiteSpace(DefaultTimeout))
return null;
// Handle formats like "30s", "5m", "1h", or "00:00:30"
var value = DefaultTimeout.Trim();
if (value.EndsWith("s", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var seconds))
return TimeSpan.FromSeconds(seconds);
}
else if (value.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var minutes))
return TimeSpan.FromMinutes(minutes);
}
else if (value.EndsWith("h", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var hours))
return TimeSpan.FromHours(hours);
}
else if (TimeSpan.TryParse(value, out var timespan))
{
return timespan;
}
return null;
}
}
/// <summary>
/// Configuration for a claim requirement.
/// </summary>
public sealed class ClaimRequirementConfig
{
/// <summary>
/// Gets or sets the claim type.
/// </summary>
[YamlMember(Alias = "type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the claim value.
/// </summary>
[YamlMember(Alias = "value")]
public string? Value { get; set; }
/// <summary>
/// Converts to a ClaimRequirement model.
/// </summary>
public ClaimRequirement ToClaimRequirement() => new()
{
Type = Type,
Value = Value
};
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for loading microservice YAML configuration.
/// </summary>
public interface IMicroserviceYamlLoader
{
/// <summary>
/// Loads the microservice configuration from YAML.
/// </summary>
/// <returns>The configuration, or null if no file is configured or file doesn't exist.</returns>
MicroserviceYamlConfig? Load();
}
/// <summary>
/// Loads microservice configuration from a YAML file.
/// </summary>
public sealed class MicroserviceYamlLoader : IMicroserviceYamlLoader
{
private readonly StellaMicroserviceOptions _options;
private readonly ILogger<MicroserviceYamlLoader> _logger;
private readonly IDeserializer _deserializer;
/// <summary>
/// Initializes a new instance of the <see cref="MicroserviceYamlLoader"/> class.
/// </summary>
public MicroserviceYamlLoader(
StellaMicroserviceOptions options,
ILogger<MicroserviceYamlLoader> logger)
{
_options = options;
_logger = logger;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}
/// <inheritdoc />
public MicroserviceYamlConfig? Load()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFilePath))
{
_logger.LogDebug("No ConfigFilePath specified, skipping YAML configuration");
return null;
}
var fullPath = Path.GetFullPath(_options.ConfigFilePath);
if (!File.Exists(fullPath))
{
_logger.LogDebug("Configuration file {Path} does not exist, skipping", fullPath);
return null;
}
try
{
var yaml = File.ReadAllText(fullPath);
var config = _deserializer.Deserialize<MicroserviceYamlConfig>(yaml);
_logger.LogInformation(
"Loaded microservice configuration from {Path} with {Count} endpoint overrides",
fullPath,
config?.Endpoints?.Count ?? 0);
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load microservice configuration from {Path}", fullPath);
throw;
}
}
}

View File

@@ -1,85 +1,2 @@
using System.Text.RegularExpressions;
namespace StellaOps.Microservice;
/// <summary>
/// Matches request paths against route templates.
/// </summary>
public sealed partial class PathMatcher
{
private readonly string _template;
private readonly Regex _regex;
private readonly string[] _parameterNames;
private readonly bool _caseInsensitive;
/// <summary>
/// Gets the route template.
/// </summary>
public string Template => _template;
/// <summary>
/// Initializes a new instance of the <see cref="PathMatcher"/> class.
/// </summary>
/// <param name="template">The route template (e.g., "/api/users/{id}").</param>
/// <param name="caseInsensitive">Whether matching should be case-insensitive.</param>
public PathMatcher(string template, bool caseInsensitive = true)
{
_template = template;
_caseInsensitive = caseInsensitive;
// Extract parameter names and build regex
var paramNames = new List<string>();
var pattern = "^" + ParameterRegex().Replace(template, match =>
{
paramNames.Add(match.Groups[1].Value);
return "([^/]+)";
}) + "/?$";
var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
_regex = new Regex(pattern, options | RegexOptions.Compiled);
_parameterNames = [.. paramNames];
}
/// <summary>
/// Tries to match a path against the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <param name="parameters">The extracted path parameters if matched.</param>
/// <returns>True if the path matches.</returns>
public bool TryMatch(string path, out Dictionary<string, string> parameters)
{
parameters = [];
// Normalize path
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
var match = _regex.Match(path);
if (!match.Success)
return false;
for (int i = 0; i < _parameterNames.Length; i++)
{
parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
}
return true;
}
/// <summary>
/// Checks if a path matches the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <returns>True if the path matches.</returns>
public bool IsMatch(string path)
{
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
return _regex.IsMatch(path);
}
[GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
private static partial Regex ParameterRegex();
}
// Re-export PathMatcher from Router.Common for backwards compatibility
global using PathMatcher = StellaOps.Router.Common.PathMatcher;

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
@@ -116,6 +117,13 @@ public sealed class RequestDispatcher
RawRequestContext context,
CancellationToken cancellationToken)
{
// Ensure handler type is set
if (endpoint.HandlerType is null)
{
_logger.LogError("Endpoint {Method} {Path} has no handler type", endpoint.Method, endpoint.Path);
return RawResponse.InternalError("No handler configured");
}
// Get handler instance from DI
var handler = scopedProvider.GetService(endpoint.HandlerType);
if (handler is null)

View File

@@ -14,13 +14,16 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
private readonly StellaMicroserviceOptions _options;
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
private readonly ITransportClient _transportClient;
private readonly IMicroserviceTransport? _microserviceTransport;
private readonly ILogger<RouterConnectionManager> _logger;
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList<EndpointDescriptor>? _endpoints;
private Task? _heartbeatTask;
private bool _disposed;
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
private int _inFlightRequestCount;
private double _errorRate;
/// <inheritdoc />
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
@@ -31,15 +34,42 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
public RouterConnectionManager(
IOptions<StellaMicroserviceOptions> options,
IEndpointDiscoveryProvider endpointDiscovery,
ITransportClient transportClient,
IMicroserviceTransport? microserviceTransport,
ILogger<RouterConnectionManager> logger)
{
_options = options.Value;
_endpointDiscovery = endpointDiscovery;
_transportClient = transportClient;
_microserviceTransport = microserviceTransport;
_logger = logger;
}
/// <summary>
/// Gets or sets the current health status reported by this instance.
/// </summary>
public InstanceHealthStatus CurrentStatus
{
get => _currentStatus;
set => _currentStatus = value;
}
/// <summary>
/// Gets or sets the count of in-flight requests.
/// </summary>
public int InFlightRequestCount
{
get => _inFlightRequestCount;
set => _inFlightRequestCount = value;
}
/// <summary>
/// Gets or sets the error rate (0.0 to 1.0).
/// </summary>
public double ErrorRate
{
get => _errorRate;
set => _errorRate = value;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
@@ -168,32 +198,40 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
foreach (var connection in _connections.Values)
// Build heartbeat payload with current status and metrics
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = DateTime.UtcNow
};
// Send heartbeat via transport
if (_microserviceTransport is not null)
{
try
{
// Build heartbeat payload
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = connection.Status,
TimestampUtc = DateTime.UtcNow
};
// Update last heartbeat time
connection.LastHeartbeatUtc = DateTime.UtcNow;
await _microserviceTransport.SendHeartbeatAsync(heartbeat, cancellationToken);
_logger.LogDebug(
"Sent heartbeat for connection {ConnectionId}",
connection.ConnectionId);
"Sent heartbeat: status={Status}, inflight={InFlight}, errorRate={ErrorRate:P1}",
heartbeat.Status,
heartbeat.InFlightRequestCount,
heartbeat.ErrorRate);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send heartbeat for connection {ConnectionId}",
connection.ConnectionId);
_logger.LogWarning(ex, "Failed to send heartbeat");
}
}
// Update connection state local heartbeat times
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = DateTime.UtcNow;
}
}
catch (OperationCanceledException)
{

View File

@@ -22,17 +22,34 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Configure options
// Configure and register options as singleton
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
services.AddSingleton(options);
services.Configure(configure);
// Register endpoint discovery
services.TryAddSingleton<IEndpointDiscoveryProvider>(sp =>
// Register YAML loader and merger
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
// Register endpoint discovery provider (prefers generated over reflection)
services.TryAddSingleton<IEndpointDiscoveryProvider, GeneratedEndpointDiscoveryProvider>();
// Register endpoint discovery service (with YAML integration)
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
// Register endpoint registry (using discovery service)
services.TryAddSingleton<IEndpointRegistry>(sp =>
{
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
return new ReflectionEndpointDiscoveryProvider(options);
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
var registry = new EndpointRegistry();
registry.RegisterAll(discoveryService.DiscoverEndpoints());
return registry;
});
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -57,12 +74,34 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Configure options
// Configure and register options as singleton
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
services.AddSingleton(options);
services.Configure(configure);
// Register YAML loader and merger
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
// Register custom endpoint discovery
services.TryAddSingleton<IEndpointDiscoveryProvider, TDiscovery>();
// Register endpoint discovery service (with YAML integration)
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
// Register endpoint registry (using discovery service)
services.TryAddSingleton<IEndpointRegistry>(sp =>
{
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
var registry = new EndpointRegistry();
registry.RegisterAll(discoveryService.DiscoverEndpoints());
return registry;
});
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -71,4 +110,17 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers an endpoint handler type for dependency injection.
/// </summary>
/// <typeparam name="THandler">The endpoint handler type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaEndpoint<THandler>(this IServiceCollection services)
where THandler : class, IStellaEndpoint
{
services.AddScoped<THandler>();
return services;
}
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />

View File

@@ -0,0 +1,164 @@
using System.Threading.Channels;
namespace StellaOps.Microservice.Streaming;
/// <summary>
/// A read-only stream that reads from a channel of data chunks.
/// Used to expose streaming request body to handlers.
/// </summary>
public sealed class StreamingRequestBodyStream : Stream
{
private readonly ChannelReader<StreamChunk> _reader;
private readonly CancellationToken _cancellationToken;
private byte[] _currentBuffer = [];
private int _currentBufferPosition;
private bool _endOfStream;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamingRequestBodyStream"/> class.
/// </summary>
/// <param name="reader">The channel reader for incoming chunks.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public StreamingRequestBodyStream(
ChannelReader<StreamChunk> reader,
CancellationToken cancellationToken)
{
_reader = reader;
_cancellationToken = cancellationToken;
}
/// <inheritdoc />
public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException("Streaming body length unknown.");
/// <inheritdoc />
public override long Position
{
get => throw new NotSupportedException("Streaming body position not supported.");
set => throw new NotSupportedException("Streaming body position not supported.");
}
/// <inheritdoc />
public override void Flush() { }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count, CancellationToken.None)
.GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
}
/// <inheritdoc />
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_endOfStream)
{
return 0;
}
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
// Try to use remaining data from current buffer first
if (_currentBufferPosition < _currentBuffer.Length)
{
var bytesToCopy = Math.Min(buffer.Length, _currentBuffer.Length - _currentBufferPosition);
_currentBuffer.AsSpan(_currentBufferPosition, bytesToCopy).CopyTo(buffer.Span);
_currentBufferPosition += bytesToCopy;
return bytesToCopy;
}
// Need to read next chunk from channel
if (!await _reader.WaitToReadAsync(linkedCts.Token))
{
_endOfStream = true;
return 0;
}
if (!_reader.TryRead(out var chunk))
{
_endOfStream = true;
return 0;
}
if (chunk.EndOfStream)
{
_endOfStream = true;
// Still process any data in the final chunk
if (chunk.Data.Length == 0)
{
return 0;
}
}
_currentBuffer = chunk.Data;
_currentBufferPosition = 0;
var bytesToReturn = Math.Min(buffer.Length, _currentBuffer.Length);
_currentBuffer.AsSpan(0, bytesToReturn).CopyTo(buffer.Span);
_currentBufferPosition = bytesToReturn;
return bytesToReturn;
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException("Seeking not supported on streaming body.");
}
/// <inheritdoc />
public override void SetLength(long value)
{
throw new NotSupportedException("Setting length not supported on streaming body.");
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Write not supported on streaming body.");
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
_disposed = true;
base.Dispose(disposing);
}
}
/// <summary>
/// Represents a chunk of streaming data.
/// </summary>
public sealed record StreamChunk
{
/// <summary>
/// Gets the chunk data.
/// </summary>
public byte[] Data { get; init; } = [];
/// <summary>
/// Gets a value indicating whether this is the final chunk.
/// </summary>
public bool EndOfStream { get; init; }
/// <summary>
/// Gets the sequence number.
/// </summary>
public int SequenceNumber { get; init; }
}

View File

@@ -0,0 +1,191 @@
using System.Threading.Channels;
namespace StellaOps.Microservice.Streaming;
/// <summary>
/// A write-only stream that writes chunks to a channel.
/// Used to enable streaming response body from handlers.
/// </summary>
public sealed class StreamingResponseBodyStream : Stream
{
private readonly ChannelWriter<StreamChunk> _writer;
private readonly int _chunkSize;
private readonly CancellationToken _cancellationToken;
private byte[] _buffer;
private int _bufferPosition;
private int _sequenceNumber;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamingResponseBodyStream"/> class.
/// </summary>
/// <param name="writer">The channel writer for outgoing chunks.</param>
/// <param name="chunkSize">The chunk size for buffered writes.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public StreamingResponseBodyStream(
ChannelWriter<StreamChunk> writer,
int chunkSize,
CancellationToken cancellationToken)
{
_writer = writer;
_chunkSize = chunkSize;
_cancellationToken = cancellationToken;
_buffer = new byte[chunkSize];
}
/// <inheritdoc />
public override bool CanRead => false;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => true;
/// <inheritdoc />
public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
/// <inheritdoc />
public override void Flush()
{
FlushAsync(CancellationToken.None).GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task FlushAsync(CancellationToken cancellationToken)
{
if (_bufferPosition > 0)
{
var chunk = new StreamChunk
{
Data = _buffer[.._bufferPosition],
SequenceNumber = _sequenceNumber++,
EndOfStream = false
};
await _writer.WriteAsync(chunk, cancellationToken);
_buffer = new byte[_chunkSize];
_bufferPosition = 0;
}
}
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Read not supported on streaming response body.");
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException("Seeking not supported on streaming response body.");
}
/// <inheritdoc />
public override void SetLength(long value)
{
throw new NotSupportedException("Setting length not supported on streaming response body.");
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
}
/// <inheritdoc />
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
var bytesWritten = 0;
while (bytesWritten < buffer.Length)
{
var spaceInBuffer = _chunkSize - _bufferPosition;
var bytesToWrite = Math.Min(spaceInBuffer, buffer.Length - bytesWritten);
buffer.Slice(bytesWritten, bytesToWrite).Span.CopyTo(_buffer.AsSpan(_bufferPosition));
_bufferPosition += bytesToWrite;
bytesWritten += bytesToWrite;
if (_bufferPosition >= _chunkSize)
{
await FlushAsync(linkedCts.Token);
}
}
}
/// <summary>
/// Completes the stream by flushing remaining data and sending end-of-stream signal.
/// </summary>
public async Task CompleteAsync(CancellationToken cancellationToken = default)
{
// Flush any remaining buffered data
await FlushAsync(cancellationToken);
// Send end-of-stream marker
var endChunk = new StreamChunk
{
Data = [],
SequenceNumber = _sequenceNumber++,
EndOfStream = true
};
await _writer.WriteAsync(endChunk, cancellationToken);
_writer.Complete();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
// Try to complete the stream if not already completed
try
{
_writer.TryComplete();
}
catch
{
// Ignore errors during disposal
}
}
_disposed = true;
base.Dispose(disposing);
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
if (!_disposed)
{
try
{
await CompleteAsync(CancellationToken.None);
}
catch
{
// Ignore errors during disposal
}
}
_disposed = true;
await base.DisposeAsync();
}
}

View File

@@ -7,6 +7,38 @@ namespace StellaOps.Router.Common.Abstractions;
/// </summary>
public interface IGlobalRoutingState
{
/// <summary>
/// Adds a connection to the routing state.
/// </summary>
/// <param name="connection">The connection state to add.</param>
void AddConnection(ConnectionState connection);
/// <summary>
/// Removes a connection from the routing state.
/// </summary>
/// <param name="connectionId">The connection ID to remove.</param>
void RemoveConnection(string connectionId);
/// <summary>
/// Updates an existing connection's state.
/// </summary>
/// <param name="connectionId">The connection ID to update.</param>
/// <param name="update">The update action to apply.</param>
void UpdateConnection(string connectionId, Action<ConnectionState> update);
/// <summary>
/// Gets a connection by its ID.
/// </summary>
/// <param name="connectionId">The connection ID.</param>
/// <returns>The connection state, or null if not found.</returns>
ConnectionState? GetConnection(string connectionId);
/// <summary>
/// Gets all active connections.
/// </summary>
/// <returns>All active connections.</returns>
IReadOnlyList<ConnectionState> GetAllConnections();
/// <summary>
/// Resolves an HTTP request to an endpoint descriptor.
/// </summary>

View File

@@ -0,0 +1,43 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Common.Abstractions;
/// <summary>
/// Represents a transport connection from a microservice to the gateway.
/// This interface is used by the Microservice SDK to communicate with the router.
/// </summary>
public interface IMicroserviceTransport
{
/// <summary>
/// Connects to the router and registers the microservice.
/// </summary>
/// <param name="instance">The instance descriptor.</param>
/// <param name="endpoints">The endpoints to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
CancellationToken cancellationToken);
/// <summary>
/// Disconnects from the router.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Sends a heartbeat to the router.
/// </summary>
/// <param name="heartbeat">The heartbeat payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken);
/// <summary>
/// Event raised when a REQUEST frame is received from the gateway.
/// </summary>
event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
/// <summary>
/// Event raised when a CANCEL frame is received from the gateway.
/// </summary>
event Func<Guid, string?, Task>? OnCancelReceived;
}

View File

@@ -0,0 +1,148 @@
using System.Text.Json;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Converts between generic Frame and typed frame records.
/// </summary>
public static class FrameConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Converts a RequestFrame to a generic Frame for transport.
/// </summary>
public static Frame ToFrame(RequestFrame request)
{
var envelope = new RequestEnvelope
{
RequestId = request.RequestId,
Method = request.Method,
Path = request.Path,
Headers = request.Headers,
TimeoutSeconds = request.TimeoutSeconds,
SupportsStreaming = request.SupportsStreaming,
Payload = request.Payload.ToArray()
};
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
return new Frame
{
Type = FrameType.Request,
CorrelationId = request.CorrelationId ?? request.RequestId,
Payload = envelopeBytes
};
}
/// <summary>
/// Converts a generic Frame to a RequestFrame.
/// </summary>
public static RequestFrame? ToRequestFrame(Frame frame)
{
if (frame.Type != FrameType.Request)
return null;
try
{
var envelope = JsonSerializer.Deserialize<RequestEnvelope>(frame.Payload.Span, JsonOptions);
if (envelope is null)
return null;
return new RequestFrame
{
RequestId = envelope.RequestId,
CorrelationId = frame.CorrelationId,
Method = envelope.Method,
Path = envelope.Path,
Headers = envelope.Headers ?? new Dictionary<string, string>(),
TimeoutSeconds = envelope.TimeoutSeconds,
SupportsStreaming = envelope.SupportsStreaming,
Payload = envelope.Payload ?? []
};
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Converts a ResponseFrame to a generic Frame for transport.
/// </summary>
public static Frame ToFrame(ResponseFrame response)
{
var envelope = new ResponseEnvelope
{
RequestId = response.RequestId,
StatusCode = response.StatusCode,
Headers = response.Headers,
HasMoreChunks = response.HasMoreChunks,
Payload = response.Payload.ToArray()
};
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
return new Frame
{
Type = FrameType.Response,
CorrelationId = response.RequestId,
Payload = envelopeBytes
};
}
/// <summary>
/// Converts a generic Frame to a ResponseFrame.
/// </summary>
public static ResponseFrame? ToResponseFrame(Frame frame)
{
if (frame.Type != FrameType.Response)
return null;
try
{
var envelope = JsonSerializer.Deserialize<ResponseEnvelope>(frame.Payload.Span, JsonOptions);
if (envelope is null)
return null;
return new ResponseFrame
{
RequestId = envelope.RequestId,
StatusCode = envelope.StatusCode,
Headers = envelope.Headers ?? new Dictionary<string, string>(),
HasMoreChunks = envelope.HasMoreChunks,
Payload = envelope.Payload ?? []
};
}
catch (JsonException)
{
return null;
}
}
private sealed class RequestEnvelope
{
public required string RequestId { get; set; }
public required string Method { get; set; }
public required string Path { get; set; }
public IReadOnlyDictionary<string, string>? Headers { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public bool SupportsStreaming { get; set; }
public byte[]? Payload { get; set; }
}
private sealed class ResponseEnvelope
{
public required string RequestId { get; set; }
public int StatusCode { get; set; } = 200;
public IReadOnlyDictionary<string, string>? Headers { get; set; }
public bool HasMoreChunks { get; set; }
public byte[]? Payload { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Represents a REQUEST frame sent from gateway to microservice.
/// </summary>
public sealed record RequestFrame
{
/// <summary>
/// Gets the unique request ID for this request.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Gets the correlation ID for distributed tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Gets the HTTP method (GET, POST, PUT, DELETE, etc.).
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Gets the request path.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Gets the request headers.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Gets the request payload (body).
/// </summary>
public ReadOnlyMemory<byte> Payload { get; init; }
/// <summary>
/// Gets the timeout in seconds for this request.
/// </summary>
public int TimeoutSeconds { get; init; } = 30;
/// <summary>
/// Gets whether this request supports streaming response.
/// </summary>
public bool SupportsStreaming { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Router.Common.Frames;
/// <summary>
/// Represents a RESPONSE frame sent from microservice to gateway.
/// </summary>
public sealed record ResponseFrame
{
/// <summary>
/// Gets the request ID this response is for.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Gets the HTTP status code.
/// </summary>
public int StatusCode { get; init; } = 200;
/// <summary>
/// Gets the response headers.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Gets the response payload (body).
/// </summary>
public ReadOnlyMemory<byte> Payload { get; init; }
/// <summary>
/// Gets whether there are more streaming chunks to follow.
/// </summary>
public bool HasMoreChunks { get; init; }
}

View File

@@ -10,3 +10,34 @@ public sealed record CancelPayload
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Standard reasons for request cancellation.
/// </summary>
public static class CancelReasons
{
/// <summary>
/// The HTTP client disconnected before the request completed.
/// </summary>
public const string ClientDisconnected = "ClientDisconnected";
/// <summary>
/// The request exceeded its timeout.
/// </summary>
public const string Timeout = "Timeout";
/// <summary>
/// The request or response payload exceeded configured limits.
/// </summary>
public const string PayloadLimitExceeded = "PayloadLimitExceeded";
/// <summary>
/// The gateway or microservice is shutting down.
/// </summary>
public const string Shutdown = "Shutdown";
/// <summary>
/// The transport connection was closed unexpectedly.
/// </summary>
public const string ConnectionClosed = "ConnectionClosed";
}

View File

@@ -39,4 +39,10 @@ public sealed record EndpointDescriptor
/// Gets a value indicating whether this endpoint supports streaming.
/// </summary>
public bool SupportsStreaming { get; init; }
/// <summary>
/// Gets the handler type that processes requests for this endpoint.
/// This is used by the Microservice SDK for handler resolution.
/// </summary>
public Type? HandlerType { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Payload for streaming data frames (REQUEST_STREAM_DATA/RESPONSE_STREAM_DATA).
/// </summary>
public sealed record StreamDataPayload
{
/// <summary>
/// Gets the correlation ID linking stream data to the original request.
/// </summary>
public required Guid CorrelationId { get; init; }
/// <summary>
/// Gets the stream data chunk.
/// </summary>
public byte[] Data { get; init; } = [];
/// <summary>
/// Gets a value indicating whether this is the final chunk.
/// </summary>
public bool EndOfStream { get; init; }
/// <summary>
/// Gets the sequence number for ordering.
/// </summary>
public int SequenceNumber { get; init; }
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Router.Common.Models;
/// <summary>
/// Configuration options for streaming operations.
/// </summary>
public sealed record StreamingOptions
{
/// <summary>
/// Gets the default streaming options.
/// </summary>
public static readonly StreamingOptions Default = new();
/// <summary>
/// Gets the size of each chunk when streaming data.
/// Default: 64 KB.
/// </summary>
public int ChunkSize { get; init; } = 64 * 1024;
/// <summary>
/// Gets the maximum number of concurrent streams per connection.
/// Default: 100.
/// </summary>
public int MaxConcurrentStreams { get; init; } = 100;
/// <summary>
/// Gets the timeout for idle streams (no data flowing).
/// Default: 5 minutes.
/// </summary>
public TimeSpan StreamIdleTimeout { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the channel capacity for buffered stream data.
/// Default: 16 chunks.
/// </summary>
public int ChannelCapacity { get; init; } = 16;
}

View File

@@ -0,0 +1,85 @@
using System.Text.RegularExpressions;
namespace StellaOps.Router.Common;
/// <summary>
/// Matches request paths against route templates.
/// </summary>
public sealed partial class PathMatcher
{
private readonly string _template;
private readonly Regex _regex;
private readonly string[] _parameterNames;
private readonly bool _caseInsensitive;
/// <summary>
/// Gets the route template.
/// </summary>
public string Template => _template;
/// <summary>
/// Initializes a new instance of the <see cref="PathMatcher"/> class.
/// </summary>
/// <param name="template">The route template (e.g., "/api/users/{id}").</param>
/// <param name="caseInsensitive">Whether matching should be case-insensitive.</param>
public PathMatcher(string template, bool caseInsensitive = true)
{
_template = template;
_caseInsensitive = caseInsensitive;
// Extract parameter names and build regex
var paramNames = new List<string>();
var pattern = "^" + ParameterRegex().Replace(template, match =>
{
paramNames.Add(match.Groups[1].Value);
return "([^/]+)";
}) + "/?$";
var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
_regex = new Regex(pattern, options | RegexOptions.Compiled);
_parameterNames = [.. paramNames];
}
/// <summary>
/// Tries to match a path against the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <param name="parameters">The extracted path parameters if matched.</param>
/// <returns>True if the path matches.</returns>
public bool TryMatch(string path, out Dictionary<string, string> parameters)
{
parameters = [];
// Normalize path
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
var match = _regex.Match(path);
if (!match.Success)
return false;
for (int i = 0; i < _parameterNames.Length; i++)
{
parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
}
return true;
}
/// <summary>
/// Checks if a path matches the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <returns>True if the path matches.</returns>
public bool IsMatch(string path)
{
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
return _regex.IsMatch(path);
}
[GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
private static partial Regex ParameterRegex();
}

View File

@@ -0,0 +1,94 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Provides access to router configuration with hot-reload support.
/// </summary>
public interface IRouterConfigProvider
{
/// <summary>
/// Gets the current router configuration.
/// </summary>
RouterConfig Current { get; }
/// <summary>
/// Gets the current router configuration options.
/// </summary>
RouterConfigOptions Options { get; }
/// <summary>
/// Raised when the configuration is reloaded.
/// </summary>
event EventHandler<ConfigChangedEventArgs>? ConfigurationChanged;
/// <summary>
/// Reloads the configuration from the source.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the reload operation.</returns>
Task ReloadAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Validates the current configuration.
/// </summary>
/// <returns>Validation result.</returns>
ConfigValidationResult Validate();
}
/// <summary>
/// Event arguments for configuration changes.
/// </summary>
public sealed class ConfigChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConfigChangedEventArgs"/> class.
/// </summary>
/// <param name="previous">The previous configuration.</param>
/// <param name="current">The current configuration.</param>
public ConfigChangedEventArgs(RouterConfig previous, RouterConfig current)
{
Previous = previous;
Current = current;
ChangedAt = DateTime.UtcNow;
}
/// <summary>
/// Gets the previous configuration.
/// </summary>
public RouterConfig Previous { get; }
/// <summary>
/// Gets the current configuration.
/// </summary>
public RouterConfig Current { get; }
/// <summary>
/// Gets the time the configuration was changed.
/// </summary>
public DateTime ChangedAt { get; }
}
/// <summary>
/// Result of configuration validation.
/// </summary>
public sealed class ConfigValidationResult
{
/// <summary>
/// Gets whether the configuration is valid.
/// </summary>
public bool IsValid => Errors.Count == 0;
/// <summary>
/// Gets the validation errors.
/// </summary>
public List<string> Errors { get; init; } = [];
/// <summary>
/// Gets the validation warnings.
/// </summary>
public List<string> Warnings { get; init; } = [];
/// <summary>
/// A successful validation result.
/// </summary>
public static ConfigValidationResult Success => new();
}

View File

@@ -12,8 +12,18 @@ public sealed class RouterConfig
/// </summary>
public PayloadLimits PayloadLimits { get; set; } = new();
/// <summary>
/// Gets or sets the routing options.
/// </summary>
public RoutingOptions Routing { get; set; } = new();
/// <summary>
/// Gets or sets the service configurations.
/// </summary>
public List<ServiceConfig> Services { get; set; } = [];
/// <summary>
/// Gets or sets the static instance configurations.
/// </summary>
public List<StaticInstanceConfig> StaticInstances { get; set; } = [];
}

Some files were not shown because too many files have changed in this diff Show More