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) <noreply@anthropic.com>
This commit is contained in:
17
etc/llm-providers/dummy.yaml
Normal file
17
etc/llm-providers/dummy.yaml
Normal file
@@ -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
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DummyLlmProvider : ILlmProvider
|
||||||
|
{
|
||||||
|
public string ProviderId => "dummy";
|
||||||
|
|
||||||
|
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<LlmCompletionResult> 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<LlmStreamChunk> 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}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin registration for the dummy provider.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -135,6 +135,7 @@ public static class LlmProviderPluginExtensions
|
|||||||
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
||||||
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
||||||
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
||||||
|
catalog.RegisterPlugin(new DummyLlmProviderPlugin());
|
||||||
|
|
||||||
// Load configurations from directory
|
// Load configurations from directory
|
||||||
var fullPath = Path.GetFullPath(configDirectory);
|
var fullPath = Path.GetFullPath(configDirectory);
|
||||||
@@ -170,6 +171,7 @@ public static class LlmProviderPluginExtensions
|
|||||||
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
|
||||||
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
|
||||||
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
|
||||||
|
catalog.RegisterPlugin(new DummyLlmProviderPlugin());
|
||||||
|
|
||||||
configureCatalog(catalog);
|
configureCatalog(catalog);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||||
|
using StellaOps.Concelier.Core.Jobs;
|
||||||
using StellaOps.Concelier.Core.Sources;
|
using StellaOps.Concelier.Core.Sources;
|
||||||
|
|
||||||
namespace StellaOps.Concelier.WebService.Extensions;
|
namespace StellaOps.Concelier.WebService.Extensions;
|
||||||
@@ -220,6 +221,69 @@ internal static class SourceManagementEndpointExtensions
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.RequireAuthorization(SourcesManagePolicy);
|
.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<object>(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
|
// GET /{sourceId}/check-result — get last check result for a source
|
||||||
group.MapGet("/{sourceId}/check-result", (
|
group.MapGet("/{sourceId}/check-result", (
|
||||||
string sourceId,
|
string sourceId,
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Open Source Vulnerabilities database",
|
Description = "Open Source Vulnerabilities database",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "OsvClient",
|
HttpClientName = "OsvClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
StatusPageUrl = "https://osv.dev",
|
StatusPageUrl = "https://osv.dev",
|
||||||
@@ -359,7 +359,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Oracle Security Alerts",
|
Description = "Oracle Security Alerts",
|
||||||
BaseEndpoint = "https://www.oracle.com/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",
|
HttpClientName = "OracleClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 50,
|
DefaultPriority = 50,
|
||||||
@@ -607,7 +607,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "npm Security Advisories (via OSV)",
|
Description = "npm Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "NpmClient",
|
HttpClientName = "NpmClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 50,
|
DefaultPriority = 50,
|
||||||
@@ -623,7 +623,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Python Package Index Security Advisories (via OSV)",
|
Description = "Python Package Index Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "PyPiClient",
|
HttpClientName = "PyPiClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 52,
|
DefaultPriority = 52,
|
||||||
@@ -655,7 +655,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "RubyGems Security Advisories (via OSV)",
|
Description = "RubyGems Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "RubyGemsClient",
|
HttpClientName = "RubyGemsClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 56,
|
DefaultPriority = 56,
|
||||||
@@ -688,7 +688,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Maven Central Security Advisories (via OSV)",
|
Description = "Maven Central Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "MavenClient",
|
HttpClientName = "MavenClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 60,
|
DefaultPriority = 60,
|
||||||
@@ -720,7 +720,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "PHP Packagist Security Advisories (via OSV)",
|
Description = "PHP Packagist Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "PackagistClient",
|
HttpClientName = "PackagistClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 64,
|
DefaultPriority = 64,
|
||||||
@@ -736,7 +736,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Elixir Hex.pm Security Advisories (via OSV)",
|
Description = "Elixir Hex.pm Security Advisories (via OSV)",
|
||||||
BaseEndpoint = "https://api.osv.dev/v1",
|
BaseEndpoint = "https://api.osv.dev/v1",
|
||||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
HealthCheckEndpoint = "https://osv.dev/",
|
||||||
HttpClientName = "HexClient",
|
HttpClientName = "HexClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 66,
|
DefaultPriority = 66,
|
||||||
@@ -754,7 +754,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Common Security Advisory Framework",
|
Description = "Common Security Advisory Framework",
|
||||||
BaseEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
BaseEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
||||||
HealthCheckEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
HealthCheckEndpoint = "https://csaf.io/",
|
||||||
HttpClientName = "CsafClient",
|
HttpClientName = "CsafClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 70,
|
DefaultPriority = 70,
|
||||||
@@ -784,7 +784,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Vulnerability Exploitability eXchange documents",
|
Description = "Vulnerability Exploitability eXchange documents",
|
||||||
BaseEndpoint = "https://vexhub.example.com/",
|
BaseEndpoint = "https://vexhub.example.com/",
|
||||||
HealthCheckEndpoint = "https://vexhub.example.com/",
|
HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/vex",
|
||||||
HttpClientName = "VexClient",
|
HttpClientName = "VexClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 74,
|
DefaultPriority = 74,
|
||||||
@@ -931,7 +931,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Offensive Security Exploit Database",
|
Description = "Offensive Security Exploit Database",
|
||||||
BaseEndpoint = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/",
|
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",
|
HttpClientName = "ExploitDbClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DocumentationUrl = "https://www.exploit-db.com/",
|
DocumentationUrl = "https://www.exploit-db.com/",
|
||||||
@@ -1030,7 +1030,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Docker Official Images CVE notices",
|
Description = "Docker Official Images CVE notices",
|
||||||
BaseEndpoint = "https://hub.docker.com/v2/",
|
BaseEndpoint = "https://hub.docker.com/v2/",
|
||||||
HealthCheckEndpoint = "https://hub.docker.com/v2/",
|
HealthCheckEndpoint = "https://hub.docker.com/",
|
||||||
HttpClientName = "DockerOfficialClient",
|
HttpClientName = "DockerOfficialClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 120,
|
DefaultPriority = 120,
|
||||||
@@ -1045,7 +1045,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Chainguard hardened image advisories",
|
Description = "Chainguard hardened image advisories",
|
||||||
BaseEndpoint = "https://images.chainguard.dev/",
|
BaseEndpoint = "https://images.chainguard.dev/",
|
||||||
HealthCheckEndpoint = "https://images.chainguard.dev/",
|
HealthCheckEndpoint = "https://www.chainguard.dev/",
|
||||||
HttpClientName = "ChainguardClient",
|
HttpClientName = "ChainguardClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 122,
|
DefaultPriority = 122,
|
||||||
@@ -1078,7 +1078,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "AMD Product Security advisories",
|
Description = "AMD Product Security advisories",
|
||||||
BaseEndpoint = "https://www.amd.com/en/resources/product-security.html",
|
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",
|
HttpClientName = "AmdClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 132,
|
DefaultPriority = 132,
|
||||||
@@ -1110,7 +1110,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Siemens Product CERT ICS advisories",
|
Description = "Siemens Product CERT ICS advisories",
|
||||||
BaseEndpoint = "https://cert-portal.siemens.com/productcert/csaf/",
|
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",
|
HttpClientName = "SiemensClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 136,
|
DefaultPriority = 136,
|
||||||
@@ -1176,7 +1176,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Ruby Advisory Database (bundler-audit)",
|
Description = "Ruby Advisory Database (bundler-audit)",
|
||||||
BaseEndpoint = "https://raw.githubusercontent.com/rubysec/ruby-advisory-db/main/",
|
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",
|
HttpClientName = "BundlerAuditClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DefaultPriority = 57,
|
DefaultPriority = 57,
|
||||||
@@ -1262,7 +1262,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Indian Computer Emergency Response Team",
|
Description = "Indian Computer Emergency Response Team",
|
||||||
BaseEndpoint = "https://www.cert-in.org.in/",
|
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",
|
HttpClientName = "CertInClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
Regions = ImmutableArray.Create("IN", "APAC"),
|
Regions = ImmutableArray.Create("IN", "APAC"),
|
||||||
@@ -1281,7 +1281,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "Federal Service for Technical and Export Control — Bank of Security Threats",
|
Description = "Federal Service for Technical and Export Control — Bank of Security Threats",
|
||||||
BaseEndpoint = "https://bdu.fstec.ru/",
|
BaseEndpoint = "https://bdu.fstec.ru/",
|
||||||
HealthCheckEndpoint = "https://bdu.fstec.ru/",
|
HealthCheckEndpoint = "http://advisory-fixture.stella-ops.local/fstec-bdu",
|
||||||
HttpClientName = "FstecBduClient",
|
HttpClientName = "FstecBduClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
Regions = ImmutableArray.Create("RU", "CIS"),
|
Regions = ImmutableArray.Create("RU", "CIS"),
|
||||||
@@ -1368,7 +1368,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.Upstream,
|
Type = SourceType.Upstream,
|
||||||
Description = "MITRE D3FEND defensive techniques knowledge base",
|
Description = "MITRE D3FEND defensive techniques knowledge base",
|
||||||
BaseEndpoint = "https://d3fend.mitre.org/api/",
|
BaseEndpoint = "https://d3fend.mitre.org/api/",
|
||||||
HealthCheckEndpoint = "https://d3fend.mitre.org/api/",
|
HealthCheckEndpoint = "https://d3fend.mitre.org/",
|
||||||
HttpClientName = "MitreD3fendClient",
|
HttpClientName = "MitreD3fendClient",
|
||||||
RequiresAuthentication = false,
|
RequiresAuthentication = false,
|
||||||
DocumentationUrl = "https://d3fend.mitre.org/",
|
DocumentationUrl = "https://d3fend.mitre.org/",
|
||||||
@@ -1387,7 +1387,7 @@ public static class SourceDefinitions
|
|||||||
Type = SourceType.StellaMirror,
|
Type = SourceType.StellaMirror,
|
||||||
Description = "StellaOps Pre-aggregated Advisory Mirror",
|
Description = "StellaOps Pre-aggregated Advisory Mirror",
|
||||||
BaseEndpoint = "https://mirror.stella-ops.org/api/v1",
|
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",
|
HttpClientName = "StellaMirrorClient",
|
||||||
RequiresAuthentication = false, // Can be configured for OAuth
|
RequiresAuthentication = false, // Can be configured for OAuth
|
||||||
StatusPageUrl = "https://status.stella-ops.org/",
|
StatusPageUrl = "https://status.stella-ops.org/",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using StellaOps.Concelier.Core.Configuration;
|
using StellaOps.Concelier.Core.Configuration;
|
||||||
|
using StellaOps.Concelier.Core.Jobs;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -27,6 +28,7 @@ public sealed class SourceRegistry : ISourceRegistry
|
|||||||
private readonly ILogger<SourceRegistry> _logger;
|
private readonly ILogger<SourceRegistry> _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly SourcesConfiguration _configuration;
|
private readonly SourcesConfiguration _configuration;
|
||||||
|
private readonly IJobCoordinator? _jobCoordinator;
|
||||||
private readonly ConcurrentDictionary<string, bool> _enabledSources;
|
private readonly ConcurrentDictionary<string, bool> _enabledSources;
|
||||||
private readonly ConcurrentDictionary<string, SourceConnectivityResult> _lastCheckResults;
|
private readonly ConcurrentDictionary<string, SourceConnectivityResult> _lastCheckResults;
|
||||||
|
|
||||||
@@ -34,12 +36,14 @@ public sealed class SourceRegistry : ISourceRegistry
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ILogger<SourceRegistry> logger,
|
ILogger<SourceRegistry> logger,
|
||||||
TimeProvider? timeProvider = null,
|
TimeProvider? timeProvider = null,
|
||||||
SourcesConfiguration? configuration = null)
|
SourcesConfiguration? configuration = null,
|
||||||
|
IJobCoordinator? jobCoordinator = null)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_configuration = configuration ?? new SourcesConfiguration();
|
_configuration = configuration ?? new SourcesConfiguration();
|
||||||
|
_jobCoordinator = jobCoordinator;
|
||||||
_sources = SourceDefinitions.All;
|
_sources = SourceDefinitions.All;
|
||||||
_enabledSources = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
_enabledSources = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||||
_lastCheckResults = new ConcurrentDictionary<string, SourceConnectivityResult>(StringComparer.OrdinalIgnoreCase);
|
_lastCheckResults = new ConcurrentDictionary<string, SourceConnectivityResult>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -272,7 +276,7 @@ public sealed class SourceRegistry : ISourceRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> EnableSourceAsync(
|
public async Task<bool> EnableSourceAsync(
|
||||||
string sourceId,
|
string sourceId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -282,12 +286,35 @@ public sealed class SourceRegistry : ISourceRegistry
|
|||||||
if (source is null)
|
if (source is null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId);
|
_logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId);
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_enabledSources[sourceId] = true;
|
_enabledSources[sourceId] = true;
|
||||||
_logger.LogInformation("Enabled source: {SourceId}", sourceId);
|
_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -310,53 +310,60 @@ public static class ApprovalEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Seed Data ----
|
// ---- Seed Data ----
|
||||||
|
// Generates relative dates so approvals always look fresh regardless of when the service starts.
|
||||||
|
|
||||||
internal static class SeedData
|
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<ApprovalDto> Approvals = new()
|
public static readonly List<ApprovalDto> Approvals = new()
|
||||||
{
|
{
|
||||||
|
// ── Pending: 1/2 approved, gates OK, normal priority ──
|
||||||
new()
|
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",
|
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.",
|
Urgency = "normal", Justification = "Scheduled release with new rate limiting feature and bug fixes.",
|
||||||
Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true,
|
Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true,
|
||||||
ExpiresAt = "2026-01-14T08:00:00Z",
|
ExpiresAt = FromNow(45),
|
||||||
GateResults = new()
|
GateResults = new()
|
||||||
{
|
{
|
||||||
new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = "2026-01-12T08:05: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 = "2026-01-12T08:06:00Z" },
|
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 = "2026-01-12T08:07:00Z" },
|
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = Ago(3) },
|
||||||
},
|
},
|
||||||
Actions = new()
|
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()
|
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" },
|
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com" },
|
||||||
},
|
},
|
||||||
ReleaseComponents = new()
|
ReleaseComponents = new()
|
||||||
{
|
{
|
||||||
new() { Name = "api-gateway", Version = "2.1.0", Digest = "sha256:abc123def456..." },
|
new() { Name = "api-gateway", Version = "2.4.1", Digest = "sha256:abc123def456" },
|
||||||
new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012..." },
|
new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012abc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Pending: 0/2 approved, gates FAILING, high priority ──
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1",
|
Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1",
|
||||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
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.",
|
Urgency = "high", Justification = "Critical fix for user authentication timeout issue.",
|
||||||
Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false,
|
Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false,
|
||||||
ExpiresAt = "2026-01-13T10:00:00Z",
|
ExpiresAt = FromNow(23),
|
||||||
GateResults = new()
|
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 = "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 = "2026-01-12T10:06:00Z" },
|
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%", EvaluatedAt = "2026-01-12T10:07:00Z" },
|
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72% (min 80%)", EvaluatedAt = Ago(1) },
|
||||||
},
|
},
|
||||||
Approvers = new()
|
Approvers = new()
|
||||||
{
|
{
|
||||||
@@ -365,43 +372,95 @@ public static class ApprovalEndpoints
|
|||||||
},
|
},
|
||||||
ReleaseComponents = new()
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2",
|
Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2",
|
||||||
SourceEnvironment = "dev", TargetEnvironment = "staging",
|
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.",
|
Urgency = "critical", Justification = "Emergency fix for payment processing failure.",
|
||||||
Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true,
|
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()
|
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-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 = "2026-01-11T15:00:00Z" },
|
new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = Ago(46) },
|
||||||
},
|
},
|
||||||
Approvers = new()
|
Approvers = new()
|
||||||
{
|
{
|
||||||
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = "2026-01-11T14:30: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 = "2026-01-11T15:00:00Z" },
|
new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = Ago(46) },
|
||||||
},
|
},
|
||||||
ReleaseComponents = new()
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0",
|
Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0",
|
||||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
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.",
|
Urgency = "low", Justification = "Feature release with new email templates.",
|
||||||
Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true,
|
Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true,
|
||||||
ExpiresAt = "2026-01-12T09:00:00Z",
|
ExpiresAt = Ago(24),
|
||||||
Actions = new()
|
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()
|
Approvers = new()
|
||||||
{
|
{
|
||||||
@@ -409,7 +468,32 @@ public static class ApprovalEndpoints
|
|||||||
},
|
},
|
||||||
ReleaseComponents = new()
|
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" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -232,22 +232,34 @@ public static class ReleaseEndpoints
|
|||||||
private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time)
|
private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time)
|
||||||
{
|
{
|
||||||
var now = time.GetUtcNow();
|
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
|
var release = new ManagedReleaseDto
|
||||||
{
|
{
|
||||||
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Version = request.Version,
|
Version = request.Version,
|
||||||
Description = request.Description ?? "",
|
Description = request.Description ?? sourceVersion?.Description ?? "",
|
||||||
Status = "draft",
|
Status = "draft",
|
||||||
CurrentEnvironment = null,
|
CurrentEnvironment = null,
|
||||||
TargetEnvironment = request.TargetEnvironment,
|
TargetEnvironment = request.TargetEnvironment ?? sourceVersion?.TargetEnvironment,
|
||||||
ComponentCount = 0,
|
ComponentCount = sourceVersion?.ComponentCount ?? 0,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
CreatedBy = "api",
|
CreatedBy = "api",
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
DeployedAt = null,
|
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);
|
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 Name { get; init; }
|
||||||
public required string Version { get; init; }
|
public required string Version { get; init; }
|
||||||
|
public string? VersionId { get; init; }
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
public string? TargetEnvironment { get; init; }
|
public string? TargetEnvironment { get; init; }
|
||||||
public string? DeploymentStrategy { get; init; }
|
public string? DeploymentStrategy { get; init; }
|
||||||
|
|||||||
Reference in New Issue
Block a user