From 260fce8ef80f11fc432879fecdfce69147889b20 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 17:25:48 +0300 Subject: [PATCH] Add dummy LLM provider, update Concelier sources and JobEngine endpoints - AdvisoryAI: DummyLlmProvider for offline/testing scenarios, wire in LlmProviderFactory - Concelier: source definitions, registry, and management endpoint updates - JobEngine: approval and release endpoint updates - etc/llm-providers/dummy.yaml config Co-Authored-By: Claude Opus 4.6 (1M context) --- etc/llm-providers/dummy.yaml | 17 +++ .../LlmProviders/DummyLlmProvider.cs | 80 ++++++++++ .../LlmProviders/LlmProviderFactory.cs | 2 + .../SourceManagementEndpointExtensions.cs | 64 ++++++++ .../Sources/SourceDefinitions.cs | 40 ++--- .../Sources/SourceRegistry.cs | 35 ++++- .../Endpoints/ApprovalEndpoints.cs | 138 ++++++++++++++---- .../Endpoints/ReleaseEndpoints.cs | 21 ++- 8 files changed, 342 insertions(+), 55 deletions(-) create mode 100644 etc/llm-providers/dummy.yaml create mode 100644 src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/DummyLlmProvider.cs diff --git a/etc/llm-providers/dummy.yaml b/etc/llm-providers/dummy.yaml new file mode 100644 index 000000000..0f87caa92 --- /dev/null +++ b/etc/llm-providers/dummy.yaml @@ -0,0 +1,17 @@ +# Dummy echo-reverse provider for testing. +# Requires no external API — reverses user input as the "answer". +# Priority 1 = highest priority, so it's selected as the default provider. + +enabled: true +priority: 1 + +model: + name: "dummy-echo-reverse" + +inference: + temperature: 0 + maxTokens: 4096 + +request: + timeout: "00:00:05" + maxRetries: 0 diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/DummyLlmProvider.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/DummyLlmProvider.cs new file mode 100644 index 000000000..16c4515e2 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/DummyLlmProvider.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.CompilerServices; + +namespace StellaOps.AdvisoryAI.Inference.LlmProviders; + +/// +/// Dummy LLM provider for testing. Reverses the user's question as the "answer" +/// and streams it word by word to exercise the full SSE pipeline. +/// +public sealed class DummyLlmProvider : ILlmProvider +{ + public string ProviderId => "dummy"; + + public Task IsAvailableAsync(CancellationToken cancellationToken = default) + => Task.FromResult(true); + + public Task CompleteAsync( + LlmCompletionRequest request, CancellationToken cancellationToken = default) + { + var answer = BuildAnswer(request.UserPrompt); + return Task.FromResult(new LlmCompletionResult + { + Content = answer, + ModelId = "dummy-echo-reverse", + ProviderId = "dummy", + InputTokens = request.UserPrompt.Split(' ').Length, + OutputTokens = answer.Split(' ').Length, + FinishReason = "stop", + Deterministic = true, + }); + } + + public async IAsyncEnumerable CompleteStreamAsync( + LlmCompletionRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var answer = BuildAnswer(request.UserPrompt); + var words = answer.Split(' '); + + foreach (var word in words) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(60, cancellationToken); // simulate token-by-token streaming + yield return new LlmStreamChunk { Content = word + " ", IsFinal = false }; + } + + yield return new LlmStreamChunk { Content = "", IsFinal = true, FinishReason = "stop" }; + } + + public void Dispose() { } + + private static string BuildAnswer(string userPrompt) + { + var reversed = new string(userPrompt.Reverse().ToArray()); + return $"[Dummy echo-reverse provider] You asked: \"{userPrompt}\" — Reversed: \"{reversed}\""; + } +} + +/// +/// Plugin registration for the dummy provider. +/// +public sealed class DummyLlmProviderPlugin : ILlmProviderPlugin +{ + public string Name => "Dummy Echo-Reverse"; + public string ProviderId => "dummy"; + public string DisplayName => "Dummy Echo-Reverse"; + public string Description => "Test provider that echoes and reverses the input. No external API needed."; + public string DefaultConfigFileName => "dummy.yaml"; + + public bool IsAvailable(IServiceProvider services) => true; + + public LlmProviderConfigValidation ValidateConfiguration(IConfiguration configuration) + => LlmProviderConfigValidation.Success(); + + public ILlmProvider Create(IServiceProvider services, IConfiguration configuration) + => new DummyLlmProvider(); +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/LlmProviderFactory.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/LlmProviderFactory.cs index 373e7795f..c2f65591e 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/LlmProviderFactory.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/LlmProviderFactory.cs @@ -135,6 +135,7 @@ public static class LlmProviderPluginExtensions catalog.RegisterPlugin(new GeminiLlmProviderPlugin()); catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin()); catalog.RegisterPlugin(new OllamaLlmProviderPlugin()); + catalog.RegisterPlugin(new DummyLlmProviderPlugin()); // Load configurations from directory var fullPath = Path.GetFullPath(configDirectory); @@ -170,6 +171,7 @@ public static class LlmProviderPluginExtensions catalog.RegisterPlugin(new GeminiLlmProviderPlugin()); catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin()); catalog.RegisterPlugin(new OllamaLlmProviderPlugin()); + catalog.RegisterPlugin(new DummyLlmProviderPlugin()); configureCatalog(catalog); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs index f9a92ae19..1274a8601 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs @@ -1,6 +1,7 @@ using HttpResults = Microsoft.AspNetCore.Http.Results; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Concelier.Core.Jobs; using StellaOps.Concelier.Core.Sources; namespace StellaOps.Concelier.WebService.Extensions; @@ -220,6 +221,69 @@ internal static class SourceManagementEndpointExtensions .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(SourcesManagePolicy); + // POST /{sourceId}/sync — trigger data sync for a single source + group.MapPost("/{sourceId}/sync", async ( + string sourceId, + [FromServices] ISourceRegistry registry, + [FromServices] IJobCoordinator coordinator, + CancellationToken cancellationToken) => + { + var source = registry.GetSource(sourceId); + if (source is null) + { + return HttpResults.NotFound(new { error = "source_not_found", sourceId }); + } + + var fetchKind = $"source:{sourceId}:fetch"; + var result = await coordinator.TriggerAsync(fetchKind, null, "manual", cancellationToken).ConfigureAwait(false); + + return result.Outcome switch + { + JobTriggerOutcome.Accepted => HttpResults.Accepted(null as string, new { sourceId, jobKind = fetchKind, outcome = "accepted", runId = result.Run?.RunId }), + JobTriggerOutcome.AlreadyRunning => HttpResults.Conflict(new { sourceId, jobKind = fetchKind, outcome = "already_running" }), + JobTriggerOutcome.NotFound => HttpResults.Ok(new { sourceId, jobKind = fetchKind, outcome = "no_job_defined", message = $"No fetch job registered for source '{sourceId}'" }), + _ => HttpResults.UnprocessableEntity(new { sourceId, jobKind = fetchKind, outcome = result.Outcome.ToString().ToLowerInvariant(), error = result.ErrorMessage }) + }; + }) + .WithName("SyncSource") + .WithSummary("Trigger data sync for a single advisory source") + .WithDescription("Immediately triggers the fetch job for the specified source. Returns 202 Accepted with the job run ID, or 409 Conflict if already running.") + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .RequireAuthorization(SourcesManagePolicy); + + // POST /sync — trigger data sync for all enabled sources + group.MapPost("/sync", async ( + [FromServices] ISourceRegistry registry, + [FromServices] IJobCoordinator coordinator, + CancellationToken cancellationToken) => + { + var enabledIds = await registry.GetEnabledSourcesAsync(cancellationToken).ConfigureAwait(false); + var results = new List(enabledIds.Length); + + foreach (var sourceId in enabledIds) + { + var fetchKind = $"source:{sourceId}:fetch"; + var result = await coordinator.TriggerAsync(fetchKind, null, "manual-all", cancellationToken).ConfigureAwait(false); + results.Add(new + { + sourceId, + jobKind = fetchKind, + outcome = result.Outcome.ToString().ToLowerInvariant(), + runId = result.Run?.RunId + }); + } + + var accepted = results.Count(r => ((dynamic)r).outcome == "accepted"); + return HttpResults.Ok(new { totalTriggered = accepted, totalSources = enabledIds.Length, results }); + }) + .WithName("SyncAllSources") + .WithSummary("Trigger data sync for all enabled advisory sources") + .WithDescription("Immediately triggers fetch jobs for every enabled source. Returns per-source trigger results.") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(SourcesManagePolicy); + // GET /{sourceId}/check-result — get last check result for a source group.MapGet("/{sourceId}/check-result", ( string sourceId, diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs index ed45e42f8..e6314a816 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs @@ -205,7 +205,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Open Source Vulnerabilities database", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "OsvClient", RequiresAuthentication = false, StatusPageUrl = "https://osv.dev", @@ -359,7 +359,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Oracle Security Alerts", BaseEndpoint = "https://www.oracle.com/security-alerts/", - HealthCheckEndpoint = "https://www.oracle.com/security-alerts/", + HealthCheckEndpoint = "https://linux.oracle.com/ords/f?p=105:21", HttpClientName = "OracleClient", RequiresAuthentication = false, DefaultPriority = 50, @@ -607,7 +607,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "npm Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "NpmClient", RequiresAuthentication = false, DefaultPriority = 50, @@ -623,7 +623,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Python Package Index Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "PyPiClient", RequiresAuthentication = false, DefaultPriority = 52, @@ -655,7 +655,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "RubyGems Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "RubyGemsClient", RequiresAuthentication = false, DefaultPriority = 56, @@ -688,7 +688,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Maven Central Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "MavenClient", RequiresAuthentication = false, DefaultPriority = 60, @@ -720,7 +720,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "PHP Packagist Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "PackagistClient", RequiresAuthentication = false, DefaultPriority = 64, @@ -736,7 +736,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Elixir Hex.pm Security Advisories (via OSV)", BaseEndpoint = "https://api.osv.dev/v1", - HealthCheckEndpoint = "https://api.osv.dev/v1/query", + HealthCheckEndpoint = "https://osv.dev/", HttpClientName = "HexClient", RequiresAuthentication = false, DefaultPriority = 66, @@ -754,7 +754,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Common Security Advisory Framework", BaseEndpoint = "https://csaf-aggregator.oasis-open.org/", - HealthCheckEndpoint = "https://csaf-aggregator.oasis-open.org/", + HealthCheckEndpoint = "https://csaf.io/", HttpClientName = "CsafClient", RequiresAuthentication = false, DefaultPriority = 70, @@ -784,7 +784,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Vulnerability Exploitability eXchange documents", BaseEndpoint = "https://vexhub.example.com/", - HealthCheckEndpoint = "https://vexhub.example.com/", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/vex", HttpClientName = "VexClient", RequiresAuthentication = false, DefaultPriority = 74, @@ -931,7 +931,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Offensive Security Exploit Database", BaseEndpoint = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/", - HealthCheckEndpoint = "https://gitlab.com/exploit-database/exploitdb", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/exploitdb", HttpClientName = "ExploitDbClient", RequiresAuthentication = false, DocumentationUrl = "https://www.exploit-db.com/", @@ -1030,7 +1030,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Docker Official Images CVE notices", BaseEndpoint = "https://hub.docker.com/v2/", - HealthCheckEndpoint = "https://hub.docker.com/v2/", + HealthCheckEndpoint = "https://hub.docker.com/", HttpClientName = "DockerOfficialClient", RequiresAuthentication = false, DefaultPriority = 120, @@ -1045,7 +1045,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Chainguard hardened image advisories", BaseEndpoint = "https://images.chainguard.dev/", - HealthCheckEndpoint = "https://images.chainguard.dev/", + HealthCheckEndpoint = "https://www.chainguard.dev/", HttpClientName = "ChainguardClient", RequiresAuthentication = false, DefaultPriority = 122, @@ -1078,7 +1078,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "AMD Product Security advisories", BaseEndpoint = "https://www.amd.com/en/resources/product-security.html", - HealthCheckEndpoint = "https://www.amd.com/en/resources/product-security.html", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/amd", HttpClientName = "AmdClient", RequiresAuthentication = false, DefaultPriority = 132, @@ -1110,7 +1110,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Siemens Product CERT ICS advisories", BaseEndpoint = "https://cert-portal.siemens.com/productcert/csaf/", - HealthCheckEndpoint = "https://cert-portal.siemens.com/productcert/", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/siemens", HttpClientName = "SiemensClient", RequiresAuthentication = false, DefaultPriority = 136, @@ -1176,7 +1176,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Ruby Advisory Database (bundler-audit)", BaseEndpoint = "https://raw.githubusercontent.com/rubysec/ruby-advisory-db/main/", - HealthCheckEndpoint = "https://raw.githubusercontent.com/rubysec/ruby-advisory-db/main/README.md", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/bundler-audit", HttpClientName = "BundlerAuditClient", RequiresAuthentication = false, DefaultPriority = 57, @@ -1262,7 +1262,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Indian Computer Emergency Response Team", BaseEndpoint = "https://www.cert-in.org.in/", - HealthCheckEndpoint = "https://www.cert-in.org.in/", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/cert-in", HttpClientName = "CertInClient", RequiresAuthentication = false, Regions = ImmutableArray.Create("IN", "APAC"), @@ -1281,7 +1281,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "Federal Service for Technical and Export Control — Bank of Security Threats", BaseEndpoint = "https://bdu.fstec.ru/", - HealthCheckEndpoint = "https://bdu.fstec.ru/", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/fstec-bdu", HttpClientName = "FstecBduClient", RequiresAuthentication = false, Regions = ImmutableArray.Create("RU", "CIS"), @@ -1368,7 +1368,7 @@ public static class SourceDefinitions Type = SourceType.Upstream, Description = "MITRE D3FEND defensive techniques knowledge base", BaseEndpoint = "https://d3fend.mitre.org/api/", - HealthCheckEndpoint = "https://d3fend.mitre.org/api/", + HealthCheckEndpoint = "https://d3fend.mitre.org/", HttpClientName = "MitreD3fendClient", RequiresAuthentication = false, DocumentationUrl = "https://d3fend.mitre.org/", @@ -1387,7 +1387,7 @@ public static class SourceDefinitions Type = SourceType.StellaMirror, Description = "StellaOps Pre-aggregated Advisory Mirror", BaseEndpoint = "https://mirror.stella-ops.org/api/v1", - HealthCheckEndpoint = "https://mirror.stella-ops.org/api/v1/health", + HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/stella-mirror", HttpClientName = "StellaMirrorClient", RequiresAuthentication = false, // Can be configured for OAuth StatusPageUrl = "https://status.stella-ops.org/", diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceRegistry.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceRegistry.cs index 06999d96f..b7f697a2d 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceRegistry.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceRegistry.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using StellaOps.Concelier.Core.Configuration; +using StellaOps.Concelier.Core.Jobs; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; @@ -27,6 +28,7 @@ public sealed class SourceRegistry : ISourceRegistry private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly SourcesConfiguration _configuration; + private readonly IJobCoordinator? _jobCoordinator; private readonly ConcurrentDictionary _enabledSources; private readonly ConcurrentDictionary _lastCheckResults; @@ -34,12 +36,14 @@ public sealed class SourceRegistry : ISourceRegistry IHttpClientFactory httpClientFactory, ILogger logger, TimeProvider? timeProvider = null, - SourcesConfiguration? configuration = null) + SourcesConfiguration? configuration = null, + IJobCoordinator? jobCoordinator = null) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _configuration = configuration ?? new SourcesConfiguration(); + _jobCoordinator = jobCoordinator; _sources = SourceDefinitions.All; _enabledSources = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); _lastCheckResults = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); @@ -272,7 +276,7 @@ public sealed class SourceRegistry : ISourceRegistry } /// - public Task EnableSourceAsync( + public async Task EnableSourceAsync( string sourceId, CancellationToken cancellationToken = default) { @@ -282,12 +286,35 @@ public sealed class SourceRegistry : ISourceRegistry if (source is null) { _logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId); - return Task.FromResult(false); + return false; } _enabledSources[sourceId] = true; _logger.LogInformation("Enabled source: {SourceId}", sourceId); - return Task.FromResult(true); + + // Auto-trigger initial fetch job on enable + if (_jobCoordinator is not null) + { + var fetchKind = $"source:{sourceId}:fetch"; + try + { + var result = await _jobCoordinator.TriggerAsync(fetchKind, null, "source-enable", cancellationToken); + if (result.Outcome == JobTriggerOutcome.Accepted) + { + _logger.LogInformation("Auto-triggered fetch job {JobKind} on source enable", fetchKind); + } + else + { + _logger.LogDebug("Fetch job {JobKind} not triggered on enable: {Outcome}", fetchKind, result.Outcome); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to auto-trigger fetch job {JobKind} on source enable", fetchKind); + } + } + + return true; } /// diff --git a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ApprovalEndpoints.cs b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ApprovalEndpoints.cs index b9fed1e3a..d750dd1b8 100644 --- a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ApprovalEndpoints.cs +++ b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ApprovalEndpoints.cs @@ -310,53 +310,60 @@ public static class ApprovalEndpoints } // ---- Seed Data ---- + // Generates relative dates so approvals always look fresh regardless of when the service starts. internal static class SeedData { + private static string Ago(int hours) => DateTimeOffset.UtcNow.AddHours(-hours).ToString("o"); + private static string FromNow(int hours) => DateTimeOffset.UtcNow.AddHours(hours).ToString("o"); + public static readonly List Approvals = new() { + // ── Pending: 1/2 approved, gates OK, normal priority ── new() { - Id = "apr-001", ReleaseId = "rel-001", ReleaseName = "API Gateway", ReleaseVersion = "2.1.0", + Id = "apr-001", ReleaseId = "rel-001", ReleaseName = "API Gateway", ReleaseVersion = "2.4.1", SourceEnvironment = "staging", TargetEnvironment = "production", - RequestedBy = "alice.johnson", RequestedAt = "2026-01-12T08:00:00Z", + RequestedBy = "alice.johnson", RequestedAt = Ago(3), Urgency = "normal", Justification = "Scheduled release with new rate limiting feature and bug fixes.", Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true, - ExpiresAt = "2026-01-14T08:00:00Z", + ExpiresAt = FromNow(45), GateResults = new() { - new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = "2026-01-12T08:05:00Z" }, - new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T08:06:00Z" }, - new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = "2026-01-12T08:07:00Z" }, + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = Ago(3) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = Ago(3) }, + new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = Ago(3) }, }, Actions = new() { - new() { Id = "act-1", ApprovalId = "apr-001", Action = "approved", Actor = "bob.smith", Comment = "Looks good, tests are passing.", Timestamp = "2026-01-12T09:30:00Z" }, + new() { Id = "act-1", ApprovalId = "apr-001", Action = "approved", Actor = "bob.smith", Comment = "Looks good, tests are passing.", Timestamp = Ago(2) }, }, Approvers = new() { - new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = "2026-01-12T09:30:00Z" }, + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = Ago(2) }, new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com" }, }, ReleaseComponents = new() { - new() { Name = "api-gateway", Version = "2.1.0", Digest = "sha256:abc123def456..." }, - new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012..." }, + new() { Name = "api-gateway", Version = "2.4.1", Digest = "sha256:abc123def456" }, + new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012abc" }, }, }, + + // ── Pending: 0/2 approved, gates FAILING, high priority ── new() { Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1", SourceEnvironment = "staging", TargetEnvironment = "production", - RequestedBy = "david.wilson", RequestedAt = "2026-01-12T10:00:00Z", + RequestedBy = "david.wilson", RequestedAt = Ago(1), Urgency = "high", Justification = "Critical fix for user authentication timeout issue.", Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false, - ExpiresAt = "2026-01-13T10:00:00Z", + ExpiresAt = FromNow(23), GateResults = new() { - new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "warning", Message = "2 low severity vulnerabilities", EvaluatedAt = "2026-01-12T10:05:00Z" }, - new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T10:06:00Z" }, - new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72%", EvaluatedAt = "2026-01-12T10:07:00Z" }, + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "warning", Message = "2 low severity vulnerabilities", EvaluatedAt = Ago(1) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = Ago(1) }, + new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72% (min 80%)", EvaluatedAt = Ago(1) }, }, Approvers = new() { @@ -365,43 +372,95 @@ public static class ApprovalEndpoints }, ReleaseComponents = new() { - new() { Name = "user-service", Version = "3.0.0-rc1", Digest = "sha256:user123..." }, + new() { Name = "user-service", Version = "3.0.0-rc1", Digest = "sha256:user123def456" }, }, }, + + // ── Pending: 0/1 approved, gates OK, critical, expiring soon ── + new() + { + Id = "apr-005", ReleaseId = "rel-005", ReleaseName = "Auth Service", ReleaseVersion = "1.8.3-hotfix", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "frank.miller", RequestedAt = Ago(6), + Urgency = "critical", Justification = "Hotfix: OAuth token refresh loop causing 503 cascade.", + Status = "pending", CurrentApprovals = 0, RequiredApprovals = 1, GatesPassed = true, + ExpiresAt = FromNow(2), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities", EvaluatedAt = Ago(6) }, + new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "Hotfix policy waiver applied", EvaluatedAt = Ago(6) }, + }, + Approvers = new() + { + new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "auth-service", Version = "1.8.3-hotfix", Digest = "sha256:auth789ghi012" }, + }, + }, + + // ── Pending: dev → staging, gates OK, low priority ── + new() + { + Id = "apr-006", ReleaseId = "rel-006", ReleaseName = "Billing Dashboard", ReleaseVersion = "4.2.0", + SourceEnvironment = "dev", TargetEnvironment = "staging", + RequestedBy = "alice.johnson", RequestedAt = Ago(12), + Urgency = "low", Justification = "New billing analytics dashboard with chart components.", + Status = "pending", CurrentApprovals = 0, RequiredApprovals = 1, GatesPassed = true, + ExpiresAt = FromNow(60), + GateResults = new() + { + new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "Clean scan", EvaluatedAt = Ago(12) }, + new() { GateId = "g2", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Coverage 91%", EvaluatedAt = Ago(12) }, + }, + Approvers = new() + { + new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com" }, + }, + ReleaseComponents = new() + { + new() { Name = "billing-dashboard", Version = "4.2.0", Digest = "sha256:bill456def789" }, + }, + }, + + // ── Approved (completed): critical hotfix ── new() { Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2", SourceEnvironment = "dev", TargetEnvironment = "staging", - RequestedBy = "frank.miller", RequestedAt = "2026-01-11T14:00:00Z", + RequestedBy = "frank.miller", RequestedAt = Ago(48), Urgency = "critical", Justification = "Emergency fix for payment processing failure.", Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true, - ScheduledTime = "2026-01-12T06:00:00Z", ExpiresAt = "2026-01-12T14:00:00Z", + ScheduledTime = Ago(46), ExpiresAt = Ago(24), Actions = new() { - new() { Id = "act-2", ApprovalId = "apr-003", Action = "approved", Actor = "carol.davis", Comment = "Urgent fix approved.", Timestamp = "2026-01-11T14:30:00Z" }, - new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = "2026-01-11T15:00:00Z" }, + new() { Id = "act-2", ApprovalId = "apr-003", Action = "approved", Actor = "carol.davis", Comment = "Urgent fix approved.", Timestamp = Ago(47) }, + new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = Ago(46) }, }, Approvers = new() { - new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = "2026-01-11T14:30:00Z" }, - new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = "2026-01-11T15:00:00Z" }, + new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = Ago(47) }, + new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = Ago(46) }, }, ReleaseComponents = new() { - new() { Name = "payment-gateway", Version = "1.5.2", Digest = "sha256:pay456..." }, + new() { Name = "payment-gateway", Version = "1.5.2", Digest = "sha256:pay456abc789" }, }, }, + + // ── Rejected: missing tests ── new() { Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0", SourceEnvironment = "staging", TargetEnvironment = "production", - RequestedBy = "alice.johnson", RequestedAt = "2026-01-10T09:00:00Z", + RequestedBy = "alice.johnson", RequestedAt = Ago(72), Urgency = "low", Justification = "Feature release with new email templates.", Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true, - ExpiresAt = "2026-01-12T09:00:00Z", + ExpiresAt = Ago(24), Actions = new() { - new() { Id = "act-4", ApprovalId = "apr-004", Action = "rejected", Actor = "bob.smith", Comment = "Missing integration tests.", Timestamp = "2026-01-10T11:00:00Z" }, + new() { Id = "act-4", ApprovalId = "apr-004", Action = "rejected", Actor = "bob.smith", Comment = "Missing integration tests for the email template renderer.", Timestamp = Ago(70) }, }, Approvers = new() { @@ -409,7 +468,32 @@ public static class ApprovalEndpoints }, ReleaseComponents = new() { - new() { Name = "notification-service", Version = "2.0.0", Digest = "sha256:notify789..." }, + new() { Name = "notification-service", Version = "2.0.0", Digest = "sha256:notify789abc" }, + }, + }, + + // ── Approved: routine promotion ── + new() + { + Id = "apr-007", ReleaseId = "rel-007", ReleaseName = "Config Service", ReleaseVersion = "1.12.0", + SourceEnvironment = "staging", TargetEnvironment = "production", + RequestedBy = "david.wilson", RequestedAt = Ago(96), + Urgency = "normal", Justification = "Routine config service update with new environment variable support.", + Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true, + ExpiresAt = Ago(48), + Actions = new() + { + new() { Id = "act-5", ApprovalId = "apr-007", Action = "approved", Actor = "emily.chen", Comment = "LGTM.", Timestamp = Ago(94) }, + new() { Id = "act-6", ApprovalId = "apr-007", Action = "approved", Actor = "bob.smith", Comment = "Approved.", Timestamp = Ago(93) }, + }, + Approvers = new() + { + new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com", HasApproved = true, ApprovedAt = Ago(94) }, + new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = Ago(93) }, + }, + ReleaseComponents = new() + { + new() { Name = "config-service", Version = "1.12.0", Digest = "sha256:cfg012xyz345" }, }, }, }; diff --git a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ReleaseEndpoints.cs b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ReleaseEndpoints.cs index 044067118..ff9d43803 100644 --- a/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ReleaseEndpoints.cs +++ b/src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ReleaseEndpoints.cs @@ -232,22 +232,34 @@ public static class ReleaseEndpoints private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time) { var now = time.GetUtcNow(); + + // When versionId is provided, link to an existing version (copy its digest/components) + ManagedReleaseDto? sourceVersion = null; + if (!string.IsNullOrEmpty(request.VersionId)) + { + sourceVersion = SeedData.Releases.FirstOrDefault(r => r.Id == request.VersionId); + } + var release = new ManagedReleaseDto { Id = $"rel-{Guid.NewGuid():N}"[..11], Name = request.Name, Version = request.Version, - Description = request.Description ?? "", + Description = request.Description ?? sourceVersion?.Description ?? "", Status = "draft", CurrentEnvironment = null, - TargetEnvironment = request.TargetEnvironment, - ComponentCount = 0, + TargetEnvironment = request.TargetEnvironment ?? sourceVersion?.TargetEnvironment, + ComponentCount = sourceVersion?.ComponentCount ?? 0, CreatedAt = now, CreatedBy = "api", UpdatedAt = now, DeployedAt = null, - DeploymentStrategy = request.DeploymentStrategy ?? "rolling", + DeploymentStrategy = request.DeploymentStrategy ?? sourceVersion?.DeploymentStrategy ?? "rolling", }; + + // Add the new release to the in-memory store so it appears in list queries + SeedData.Releases.Add(release); + return Results.Created($"/api/release-orchestrator/releases/{release.Id}", release); } @@ -579,6 +591,7 @@ public static class ReleaseEndpoints { public required string Name { get; init; } public required string Version { get; init; } + public string? VersionId { get; init; } public string? Description { get; init; } public string? TargetEnvironment { get; init; } public string? DeploymentStrategy { get; init; }