Add unit tests for Router configuration and transport layers
- 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:
@@ -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
207
src/Cli/StellaOps.Cli/Services/Models/AttestorTransportModels.cs
Normal file
207
src/Cli/StellaOps.Cli/Services/Models/AttestorTransportModels.cs
Normal 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; }
|
||||
}
|
||||
@@ -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'">
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
33ae97923c3d3f0da86474cbf5cd9318d94d0bb39ad71ff892e3a786ae264925 src/DevPortal/StellaOps.DevPortal.Site/snippets/./README.stub
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>>());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
256
src/Gateway/StellaOps.Gateway.WebService/DefaultRoutingPlugin.cs
Normal file
256
src/Gateway/StellaOps.Gateway.WebService/DefaultRoutingPlugin.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
src/Gateway/StellaOps.Gateway.WebService/HealthMonitorService.cs
Normal file
117
src/Gateway/StellaOps.Gateway.WebService/HealthMonitorService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Gateway/StellaOps.Gateway.WebService/HealthOptions.cs
Normal file
36
src/Gateway/StellaOps.Gateway.WebService/HealthOptions.cs
Normal 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;
|
||||
}
|
||||
159
src/Gateway/StellaOps.Gateway.WebService/InMemoryRoutingState.cs
Normal file
159
src/Gateway/StellaOps.Gateway.WebService/InMemoryRoutingState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/Gateway/StellaOps.Gateway.WebService/PingTracker.cs
Normal file
84
src/Gateway/StellaOps.Gateway.WebService/PingTracker.cs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
67
src/Gateway/StellaOps.Gateway.WebService/RoutingOptions.cs
Normal file
67
src/Gateway/StellaOps.Gateway.WebService/RoutingOptions.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
129
src/Web/StellaOps.Web/package-lock.json
generated
129
src/Web/StellaOps.Web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
@@ -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 (0–1).' },
|
||||
{ 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 (0–1).' },
|
||||
{ label: 'signals.entropy_penalty', kind: 5, insertText: 'signals.entropy_penalty', documentation: 'Entropy penalty (0–0.3).' },
|
||||
{ label: 'signals.uncertainty.level', kind: 5, insertText: 'signals.uncertainty.level', documentation: 'Uncertainty level (U1–U3).' },
|
||||
{ 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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Policy Studio models exports.
|
||||
*/
|
||||
|
||||
export * from './policy.models';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Policy Studio services exports.
|
||||
*/
|
||||
|
||||
export { PolicyApiService } from './policy-api.service';
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
115
src/__Libraries/StellaOps.Microservice/EndpointOverrideMerger.cs
Normal file
115
src/__Libraries/StellaOps.Microservice/EndpointOverrideMerger.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
145
src/__Libraries/StellaOps.Microservice/InflightRequestTracker.cs
Normal file
145
src/__Libraries/StellaOps.Microservice/InflightRequestTracker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/__Libraries/StellaOps.Microservice/MicroserviceYamlConfig.cs
Normal file
113
src/__Libraries/StellaOps.Microservice/MicroserviceYamlConfig.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
148
src/__Libraries/StellaOps.Router.Common/Frames/FrameConverter.cs
Normal file
148
src/__Libraries/StellaOps.Router.Common/Frames/FrameConverter.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
85
src/__Libraries/StellaOps.Router.Common/PathMatcher.cs
Normal file
85
src/__Libraries/StellaOps.Router.Common/PathMatcher.cs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user