Add JobEngine deployment compatibility store and scheduler persistence
Introduce PostgresDeploymentCompatibilityStore with migration 011, in-memory fallback, deployment endpoints, and Postgres fixture for integration tests. Update Scheduler repository with connection policy adoption. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,15 @@ public sealed class JobEngineDataSource : IAsyncDisposable
|
||||
_options = options.Value.Database;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_options.ConnectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-jobengine",
|
||||
Pooling = _options.EnablePooling,
|
||||
MinPoolSize = _options.MinPoolSize,
|
||||
MaxPoolSize = _options.MaxPoolSize,
|
||||
};
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString);
|
||||
_dataSource = builder.Build();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,20 @@ public static class ServiceCollectionExtensions
|
||||
.Bind(configuration.GetSection(JobEngineServiceOptions.SectionName))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
var explicitConnection = configuration[$"{JobEngineServiceOptions.SectionName}:Database:ConnectionString"];
|
||||
if (!string.IsNullOrWhiteSpace(explicitConnection))
|
||||
{
|
||||
options.Database.ConnectionString = explicitConnection;
|
||||
return;
|
||||
}
|
||||
|
||||
var orchestratorConnection = configuration["Orchestrator:Database:ConnectionString"];
|
||||
if (ShouldReplaceConnectionString(options.Database.ConnectionString)
|
||||
&& !string.IsNullOrWhiteSpace(orchestratorConnection))
|
||||
{
|
||||
options.Database.ConnectionString = orchestratorConnection;
|
||||
}
|
||||
|
||||
var fallbackConnection =
|
||||
configuration.GetConnectionString("Default")
|
||||
?? configuration["ConnectionStrings:Default"];
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: applied named PostgreSQL pooling/attribution to JobEngine runtime data sources and compatibility storage. |
|
||||
| S311-SCHEMA-REMEDIATION | DONE | Sprint `docs/implplan/SPRINT_20260305_311_JobEngine_consolidation_gap_remediation.md`: aligned runtime/design-time/compiled-model schema to preserved `orchestrator` default and repaired consolidation ledger links. |
|
||||
| U-002-ORCH-CONNECTION | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: harden local DB connection resolution to avoid deadletter/runtime failures from loopback connection strings in containers. |
|
||||
| AUDIT-0422-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.JobEngine.Infrastructure. |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS compatibility_deployments (
|
||||
tenant_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deployment_json JSONB NOT NULL,
|
||||
logs_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
events_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metrics_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (tenant_id, deployment_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compatibility_deployments_tenant_started_at
|
||||
ON compatibility_deployments (tenant_id, started_at DESC, deployment_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.JobEngine.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.JobEngine.Tests.ControlPlane;
|
||||
|
||||
public sealed class JobEnginePostgresFixture : PostgresIntegrationFixture
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(JobEngineDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName()
|
||||
=> "JobEngine";
|
||||
}
|
||||
@@ -2,10 +2,13 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Infrastructure;
|
||||
using StellaOps.JobEngine.Infrastructure.Options;
|
||||
using StellaOps.JobEngine.WebService;
|
||||
using StellaOps.JobEngine.WebService.Endpoints;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
@@ -18,8 +21,15 @@ using System.Text.Json;
|
||||
|
||||
namespace StellaOps.JobEngine.Tests.ControlPlane;
|
||||
|
||||
public sealed class ReleaseCompatibilityEndpointsTests
|
||||
public sealed class ReleaseCompatibilityEndpointsTests : IClassFixture<JobEnginePostgresFixture>
|
||||
{
|
||||
private readonly JobEnginePostgresFixture _postgresFixture;
|
||||
|
||||
public ReleaseCompatibilityEndpointsTests(JobEnginePostgresFixture postgresFixture)
|
||||
{
|
||||
_postgresFixture = postgresFixture;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeploymentEndpoints_ReturnSuccessForExpectedLifecycleRoutes()
|
||||
@@ -53,6 +63,111 @@ public sealed class ReleaseCompatibilityEndpointsTests
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/v1/release-orchestrator/deployments/dep-004/targets/tgt-010/retry", null, TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeploymentEndpoints_CreateDeploymentPersistsReadableDetail()
|
||||
{
|
||||
await using var app = await CreateTestAppAsync();
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-orchestrator/deployments",
|
||||
new
|
||||
{
|
||||
releaseId = "rel-live-001",
|
||||
environmentId = "env-prod-eu",
|
||||
environmentName = "EU Production",
|
||||
strategy = "all_at_once",
|
||||
strategyConfig = new { maxConcurrency = 0, failureBehavior = "rollback", healthCheckTimeout = 120 },
|
||||
packageType = "version",
|
||||
packageRefId = "ver-2026-04-04",
|
||||
packageRefName = "checkout-api@2026.04.04",
|
||||
promotionStages = new[]
|
||||
{
|
||||
new { name = "Production", environmentId = "env-prod-eu" },
|
||||
},
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
using var createDocument = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var deploymentId = createDocument.RootElement.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(deploymentId));
|
||||
Assert.Equal("all_at_once", createDocument.RootElement.GetProperty("strategy").GetString());
|
||||
Assert.Equal("pending", createDocument.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal("EU Production", createDocument.RootElement.GetProperty("environmentName").GetString());
|
||||
|
||||
var detailResponse = await client.GetAsync($"/api/v1/release-orchestrator/deployments/{deploymentId}", TestContext.Current.CancellationToken);
|
||||
detailResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var detailDocument = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("checkout-api@2026.04.04", detailDocument.RootElement.GetProperty("releaseVersion").GetString());
|
||||
Assert.True(detailDocument.RootElement.GetProperty("canCancel").GetBoolean());
|
||||
Assert.Equal("Queued for rollout", detailDocument.RootElement.GetProperty("currentStep").GetString());
|
||||
Assert.Equal(4, detailDocument.RootElement.GetProperty("targets").GetArrayLength());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task DeploymentEndpoints_PersistStateAcrossHostRestart()
|
||||
{
|
||||
await _postgresFixture.TruncateAllTablesAsync();
|
||||
|
||||
string deploymentId;
|
||||
await using (var app = await CreateTestAppAsync(_postgresFixture))
|
||||
{
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-orchestrator/deployments",
|
||||
new
|
||||
{
|
||||
releaseId = "rel-persist-001",
|
||||
environmentId = "env-prod-us",
|
||||
strategy = "rolling",
|
||||
packageType = "version",
|
||||
packageRefId = "ver-2026-04-05",
|
||||
packageRefName = "gateway@2026.04.05",
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var createDocument = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
deploymentId = createDocument.RootElement.GetProperty("id").GetString()
|
||||
?? throw new InvalidOperationException("Deployment id was not returned.");
|
||||
|
||||
var pauseResponse = await client.PostAsync(
|
||||
$"/api/v1/release-orchestrator/deployments/{deploymentId}/pause",
|
||||
content: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
pauseResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var restartedApp = await CreateTestAppAsync(_postgresFixture);
|
||||
using var restartedClient = restartedApp.GetTestClient();
|
||||
|
||||
var detailResponse = await restartedClient.GetAsync(
|
||||
$"/api/v1/release-orchestrator/deployments/{deploymentId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
detailResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var detailDocument = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("paused", detailDocument.RootElement.GetProperty("status").GetString());
|
||||
|
||||
var eventsResponse = await restartedClient.GetAsync(
|
||||
$"/api/v1/release-orchestrator/deployments/{deploymentId}/events",
|
||||
TestContext.Current.CancellationToken);
|
||||
eventsResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var eventsDocument = JsonDocument.Parse(await eventsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var eventTypes = eventsDocument.RootElement.GetProperty("events")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetProperty("type").GetString())
|
||||
.ToArray();
|
||||
Assert.Contains("paused", eventTypes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceEndpoints_VerifyHashesAndExportDeterministicBundle()
|
||||
@@ -132,13 +247,29 @@ public sealed class ReleaseCompatibilityEndpointsTests
|
||||
Assert.DoesNotContain("apr-002", pendingIds);
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> CreateTestAppAsync()
|
||||
private static async Task<WebApplication> CreateTestAppAsync(JobEnginePostgresFixture? postgresFixture = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
if (postgresFixture is not null)
|
||||
{
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JobEngine:Database:ConnectionString"] = postgresFixture.ConnectionString,
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddOptions<JobEngineServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(JobEngineServiceOptions.SectionName));
|
||||
builder.Services.AddSingleton<ReleasePromotionDecisionStore>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
if (postgresFixture is not null)
|
||||
{
|
||||
builder.Services.AddJobEngineInfrastructure(builder.Configuration);
|
||||
}
|
||||
builder.Services.AddDeploymentCompatibilityStore();
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
|
||||
<ProjectReference Include="..\StellaOps.JobEngine.Infrastructure\StellaOps.JobEngine.Infrastructure.csproj"/>
|
||||
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj"/>
|
||||
|
||||
<ProjectReference Include="..\StellaOps.JobEngine.WebService\StellaOps.JobEngine.WebService.csproj"/>
|
||||
@@ -107,4 +109,3 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -1,463 +1,431 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Deployment monitoring endpoints for the Orchestrator service.
|
||||
/// Provides lifecycle operations, log streaming, event tracking, and metrics
|
||||
/// for individual deployment runs.
|
||||
/// Routes: /api/release-orchestrator/deployments
|
||||
/// </summary>
|
||||
public static class DeploymentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDeploymentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapDeploymentGroup(app, "/api/release-orchestrator/deployments", includeRouteNames: true);
|
||||
MapDeploymentGroup(app, "/api/v1/release-orchestrator/deployments", includeRouteNames: false);
|
||||
|
||||
Map(app, "/api/release-orchestrator/deployments", true);
|
||||
Map(app, "/api/v1/release-orchestrator/deployments", false);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapDeploymentGroup(
|
||||
IEndpointRouteBuilder app,
|
||||
string prefix,
|
||||
bool includeRouteNames)
|
||||
private static void Map(IEndpointRouteBuilder app, string prefix, bool named)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("Deployments")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
// --- Read endpoints ---
|
||||
var create = group.MapPost("", CreateAsync).RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
var list = group.MapGet("", ListAsync);
|
||||
var detail = group.MapGet("/{id}", GetAsync);
|
||||
var logs = group.MapGet("/{id}/logs", GetLogsAsync);
|
||||
var targetLogs = group.MapGet("/{id}/targets/{targetId}/logs", GetTargetLogsAsync);
|
||||
var events = group.MapGet("/{id}/events", GetEventsAsync);
|
||||
var metrics = group.MapGet("/{id}/metrics", GetMetricsAsync);
|
||||
var pause = group.MapPost("/{id}/pause", PauseAsync).RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
var resume = group.MapPost("/{id}/resume", ResumeAsync).RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
var cancel = group.MapPost("/{id}/cancel", CancelAsync).RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
var rollback = group.MapPost("/{id}/rollback", RollbackAsync).RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
var retry = group.MapPost("/{id}/targets/{targetId}/retry", RetryTargetAsync).RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
|
||||
var list = group.MapGet(string.Empty, ListDeployments)
|
||||
.WithDescription("Return a paginated list of deployments for the calling tenant, optionally filtered by status, environment, and release. Each deployment record includes its current status, target environment, strategy, and lifecycle timestamps.");
|
||||
if (includeRouteNames)
|
||||
if (!named)
|
||||
{
|
||||
list.WithName("Deployment_List");
|
||||
return;
|
||||
}
|
||||
|
||||
var detail = group.MapGet("/{id}", GetDeployment)
|
||||
.WithDescription("Return the full deployment record for the specified ID including status, target environment, deployment strategy, target health, and progress details. Returns 404 when the deployment does not exist in the tenant.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
detail.WithName("Deployment_Get");
|
||||
}
|
||||
|
||||
var logs = group.MapGet("/{id}/logs", GetDeploymentLogs)
|
||||
.WithDescription("Return the aggregated log entries for the specified deployment across all targets. Entries are ordered chronologically and include severity level, source target, and message content.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
logs.WithName("Deployment_GetLogs");
|
||||
}
|
||||
|
||||
var targetLogs = group.MapGet("/{id}/targets/{targetId}/logs", GetTargetLogs)
|
||||
.WithDescription("Return log entries for a specific target within the deployment. Useful for diagnosing issues on an individual host or container instance. Returns 404 when the deployment or target does not exist.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
targetLogs.WithName("Deployment_GetTargetLogs");
|
||||
}
|
||||
|
||||
var events = group.MapGet("/{id}/events", GetDeploymentEvents)
|
||||
.WithDescription("Return the chronological event stream for the specified deployment including status transitions, health check results, target progress updates, and rollback triggers.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
events.WithName("Deployment_GetEvents");
|
||||
}
|
||||
|
||||
var metrics = group.MapGet("/{id}/metrics", GetDeploymentMetrics)
|
||||
.WithDescription("Return real-time and historical metrics for the specified deployment including duration, error rates, resource utilisation, and target-level health indicators.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
metrics.WithName("Deployment_GetMetrics");
|
||||
}
|
||||
|
||||
// --- Mutation endpoints ---
|
||||
|
||||
var pause = group.MapPost("/{id}/pause", PauseDeployment)
|
||||
.WithDescription("Pause the specified in-progress deployment, halting further target rollouts while keeping already-deployed targets running. Returns 409 if the deployment is not in a pausable state.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
pause.WithName("Deployment_Pause");
|
||||
}
|
||||
|
||||
var resume = group.MapPost("/{id}/resume", ResumeDeployment)
|
||||
.WithDescription("Resume a previously paused deployment, continuing the rollout to remaining targets from where it was halted. Returns 409 if the deployment is not currently paused.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
resume.WithName("Deployment_Resume");
|
||||
}
|
||||
|
||||
var cancel = group.MapPost("/{id}/cancel", CancelDeployment)
|
||||
.WithDescription("Cancel the specified deployment, stopping all in-progress rollouts and marking the deployment as cancelled. Already-deployed targets are not rolled back. Returns 409 if the deployment is already in a terminal state.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
cancel.WithName("Deployment_Cancel");
|
||||
}
|
||||
|
||||
var rollback = group.MapPost("/{id}/rollback", RollbackDeployment)
|
||||
.WithDescription("Initiate a rollback of the specified deployment, reverting all targets to the previous stable version. The rollback is audited and creates corresponding events. Returns 409 if the deployment is not in a rollbackable state.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
rollback.WithName("Deployment_Rollback");
|
||||
}
|
||||
|
||||
var retryTarget = group.MapPost("/{id}/targets/{targetId}/retry", RetryTarget)
|
||||
.WithDescription("Retry the deployment to a specific failed target within the deployment. Only targets in failed or error state can be retried. Returns 404 when the deployment or target does not exist; 409 when the target is not in a retryable state.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
retryTarget.WithName("Deployment_RetryTarget");
|
||||
}
|
||||
create.WithName("Deployment_Create");
|
||||
list.WithName("Deployment_List");
|
||||
detail.WithName("Deployment_Get");
|
||||
logs.WithName("Deployment_GetLogs");
|
||||
targetLogs.WithName("Deployment_GetTargetLogs");
|
||||
events.WithName("Deployment_GetEvents");
|
||||
metrics.WithName("Deployment_GetMetrics");
|
||||
pause.WithName("Deployment_Pause");
|
||||
resume.WithName("Deployment_Resume");
|
||||
cancel.WithName("Deployment_Cancel");
|
||||
rollback.WithName("Deployment_Rollback");
|
||||
retry.WithName("Deployment_RetryTarget");
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
private static async Task<IResult> CreateAsync(
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
ClaimsPrincipal user,
|
||||
[FromBody] CreateDeploymentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ReleaseId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "releaseId is required." });
|
||||
}
|
||||
|
||||
private static IResult ListDeployments(
|
||||
if (string.IsNullOrWhiteSpace(request.EnvironmentId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "environmentId is required." });
|
||||
}
|
||||
|
||||
var strategy = NormalizeStrategy(request.Strategy);
|
||||
if (strategy is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "strategy must be one of rolling, canary, blue_green, or all_at_once." });
|
||||
}
|
||||
|
||||
var actor = user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue(ClaimTypes.Name)
|
||||
?? "release-operator";
|
||||
var deployment = await store.CreateAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
request with { Strategy = strategy },
|
||||
actor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/release-orchestrator/deployments/{deployment.Id}", deployment);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? environment,
|
||||
[FromQuery] string? environments,
|
||||
[FromQuery] string? releaseId,
|
||||
[FromQuery] string? releases,
|
||||
[FromQuery] string? sortField,
|
||||
[FromQuery] string? sortOrder,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize)
|
||||
[FromQuery] int? pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deployments = SeedData.Deployments.AsEnumerable();
|
||||
IEnumerable<DeploymentSummaryDto> items = (await store.ListAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
cancellationToken).ConfigureAwait(false)).Select(ToSummary);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
var statusSet = Csv(statuses, status);
|
||||
if (statusSet.Count > 0)
|
||||
{
|
||||
var statusList = status.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
deployments = deployments.Where(d => statusList.Contains(d.Status, StringComparer.OrdinalIgnoreCase));
|
||||
items = items.Where(item => statusSet.Contains(item.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
var environmentSet = Csv(environments, environment);
|
||||
if (environmentSet.Count > 0)
|
||||
{
|
||||
deployments = deployments.Where(d =>
|
||||
string.Equals(d.Environment, environment, StringComparison.OrdinalIgnoreCase));
|
||||
items = items.Where(item =>
|
||||
environmentSet.Contains(item.EnvironmentId, StringComparer.OrdinalIgnoreCase)
|
||||
|| environmentSet.Contains(item.EnvironmentName, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(releaseId))
|
||||
var releaseSet = Csv(releases, releaseId);
|
||||
if (releaseSet.Count > 0)
|
||||
{
|
||||
deployments = deployments.Where(d =>
|
||||
string.Equals(d.ReleaseId, releaseId, StringComparison.OrdinalIgnoreCase));
|
||||
items = items.Where(item => releaseSet.Contains(item.ReleaseId, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var sorted = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch
|
||||
items = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch
|
||||
{
|
||||
("status", "asc") => deployments.OrderBy(d => d.Status),
|
||||
("status", _) => deployments.OrderByDescending(d => d.Status),
|
||||
("environment", "asc") => deployments.OrderBy(d => d.Environment),
|
||||
("environment", _) => deployments.OrderByDescending(d => d.Environment),
|
||||
(_, "asc") => deployments.OrderBy(d => d.StartedAt),
|
||||
_ => deployments.OrderByDescending(d => d.StartedAt),
|
||||
("status", "asc") => items.OrderBy(item => item.Status, StringComparer.OrdinalIgnoreCase),
|
||||
("status", _) => items.OrderByDescending(item => item.Status, StringComparer.OrdinalIgnoreCase),
|
||||
("environment", "asc") => items.OrderBy(item => item.EnvironmentName, StringComparer.OrdinalIgnoreCase),
|
||||
("environment", _) => items.OrderByDescending(item => item.EnvironmentName, StringComparer.OrdinalIgnoreCase),
|
||||
(_, "asc") => items.OrderBy(item => item.StartedAt),
|
||||
_ => items.OrderByDescending(item => item.StartedAt),
|
||||
};
|
||||
|
||||
var all = sorted.ToList();
|
||||
var effectivePage = Math.Max(page ?? 1, 1);
|
||||
var effectivePageSize = Math.Clamp(pageSize ?? 20, 1, 100);
|
||||
var items = all.Skip((effectivePage - 1) * effectivePageSize).Take(effectivePageSize).ToList();
|
||||
|
||||
var list = items.ToList();
|
||||
var resolvedPage = Math.Max(page ?? 1, 1);
|
||||
var resolvedPageSize = Math.Clamp(pageSize ?? 20, 1, 100);
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
totalCount = all.Count,
|
||||
page = effectivePage,
|
||||
pageSize = effectivePageSize,
|
||||
items = list.Skip((resolvedPage - 1) * resolvedPageSize).Take(resolvedPageSize).ToList(),
|
||||
totalCount = list.Count,
|
||||
page = resolvedPage,
|
||||
pageSize = resolvedPageSize,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetDeployment(string id)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
return deployment is not null ? Results.Ok(deployment) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult GetDeploymentLogs(
|
||||
private static async Task<IResult> GetAsync(
|
||||
string id,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] int? limit)
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SeedData.Deployments.Any(d => d.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.Ok(new { entries = Array.Empty<object>() });
|
||||
var deployment = await store.GetAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return deployment is null ? Results.NotFound() : Results.Ok(deployment);
|
||||
}
|
||||
|
||||
private static IResult GetTargetLogs(
|
||||
private static async Task<IResult> GetLogsAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await store.GetLogsAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
targetId: null,
|
||||
level,
|
||||
limit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return entries is null ? Results.NotFound() : Results.Ok(new { entries });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTargetLogsAsync(
|
||||
string id,
|
||||
string targetId,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] int? limit)
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SeedData.Deployments.Any(d => d.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.Ok(new { entries = Array.Empty<object>() });
|
||||
var entries = await store.GetLogsAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
targetId,
|
||||
level,
|
||||
limit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return entries is null ? Results.NotFound() : Results.Ok(new { entries });
|
||||
}
|
||||
|
||||
private static IResult GetDeploymentEvents(string id)
|
||||
private static async Task<IResult> GetEventsAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SeedData.Deployments.Any(d => d.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
if (!SeedData.Events.TryGetValue(id, out var events))
|
||||
return Results.Ok(new { events = Array.Empty<object>() });
|
||||
|
||||
return Results.Ok(new { events });
|
||||
var events = await store.GetEventsAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return events is null ? Results.NotFound() : Results.Ok(new { events });
|
||||
}
|
||||
|
||||
private static IResult GetDeploymentMetrics(string id)
|
||||
private static async Task<IResult> GetMetricsAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SeedData.Deployments.Any(d => d.Id == id))
|
||||
return Results.NotFound();
|
||||
var metrics = await store.GetMetricsAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return metrics is null ? Results.NotFound() : Results.Ok(new { metrics });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
private static Task<IResult> PauseAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
=> TransitionAsync(
|
||||
context,
|
||||
tenantAccessor,
|
||||
store,
|
||||
id,
|
||||
["running", "pending"],
|
||||
"paused",
|
||||
"paused",
|
||||
$"Deployment {id} paused.",
|
||||
complete: false,
|
||||
cancellationToken);
|
||||
|
||||
private static Task<IResult> ResumeAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
=> TransitionAsync(
|
||||
context,
|
||||
tenantAccessor,
|
||||
store,
|
||||
id,
|
||||
["paused"],
|
||||
"running",
|
||||
"resumed",
|
||||
$"Deployment {id} resumed.",
|
||||
complete: false,
|
||||
cancellationToken);
|
||||
|
||||
private static Task<IResult> CancelAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
=> TransitionAsync(
|
||||
context,
|
||||
tenantAccessor,
|
||||
store,
|
||||
id,
|
||||
["running", "pending", "paused"],
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
$"Deployment {id} cancelled.",
|
||||
complete: true,
|
||||
cancellationToken);
|
||||
|
||||
private static Task<IResult> RollbackAsync(
|
||||
string id,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
=> TransitionAsync(
|
||||
context,
|
||||
tenantAccessor,
|
||||
store,
|
||||
id,
|
||||
["completed", "failed", "running", "paused"],
|
||||
"rolling_back",
|
||||
"rollback_started",
|
||||
$"Rollback initiated for deployment {id}.",
|
||||
complete: false,
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<IResult> RetryTargetAsync(
|
||||
string id,
|
||||
string targetId,
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await store.RetryAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
id,
|
||||
targetId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return ToMutationResult(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TransitionAsync(
|
||||
HttpContext context,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
IDeploymentCompatibilityStore store,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> allowedStatuses,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await store.TransitionAsync(
|
||||
ResolveTenant(tenantAccessor, context),
|
||||
deploymentId,
|
||||
allowedStatuses,
|
||||
nextStatus,
|
||||
eventType,
|
||||
message,
|
||||
complete,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return ToMutationResult(result);
|
||||
}
|
||||
|
||||
private static IResult ToMutationResult(DeploymentMutationResult result)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
metrics = new
|
||||
DeploymentMutationStatus.Success => Results.Ok(new
|
||||
{
|
||||
durationSeconds = (int?)null,
|
||||
errorRate = 0.0,
|
||||
targetsTotal = 0,
|
||||
targetsCompleted = 0,
|
||||
targetsFailed = 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PauseDeployment(string id)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
if (deployment is null) return Results.NotFound();
|
||||
|
||||
if (deployment.Status is not ("in_progress" or "rolling"))
|
||||
{
|
||||
return Results.Conflict(new { success = false, message = $"Deployment {id} cannot be paused in status '{deployment.Status}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Deployment {id} paused." });
|
||||
}
|
||||
|
||||
private static IResult ResumeDeployment(string id)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
if (deployment is null) return Results.NotFound();
|
||||
|
||||
if (deployment.Status != "paused")
|
||||
{
|
||||
return Results.Conflict(new { success = false, message = $"Deployment {id} is not paused; current status is '{deployment.Status}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Deployment {id} resumed." });
|
||||
}
|
||||
|
||||
private static IResult CancelDeployment(string id)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
if (deployment is null) return Results.NotFound();
|
||||
|
||||
if (deployment.Status is "cancelled" or "completed" or "failed")
|
||||
{
|
||||
return Results.Conflict(new { success = false, message = $"Deployment {id} is already in terminal state '{deployment.Status}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Deployment {id} cancelled." });
|
||||
}
|
||||
|
||||
private static IResult RollbackDeployment(string id)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
if (deployment is null) return Results.NotFound();
|
||||
|
||||
if (deployment.Status is not ("completed" or "in_progress" or "rolling" or "paused"))
|
||||
{
|
||||
return Results.Conflict(new { success = false, message = $"Deployment {id} cannot be rolled back in status '{deployment.Status}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Rollback initiated for deployment {id}." });
|
||||
}
|
||||
|
||||
private static IResult RetryTarget(string id, string targetId)
|
||||
{
|
||||
var deployment = SeedData.Deployments.FirstOrDefault(d => d.Id == id);
|
||||
if (deployment is null) return Results.NotFound();
|
||||
|
||||
var target = deployment.Targets.FirstOrDefault(t => t.Id == targetId);
|
||||
if (target is null) return Results.NotFound();
|
||||
|
||||
if (target.Status is not ("failed" or "error"))
|
||||
{
|
||||
return Results.Conflict(new { success = false, message = $"Target {targetId} is not in a retryable state; current status is '{target.Status}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { success = true, message = $"Retry initiated for target {targetId} in deployment {id}." });
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record DeploymentDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Strategy { get; init; }
|
||||
public string? InitiatedBy { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public int TargetsTotal { get; init; }
|
||||
public int TargetsCompleted { get; init; }
|
||||
public int TargetsFailed { get; init; }
|
||||
public List<DeploymentTargetDto> Targets { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record DeploymentTargetDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Host { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentEventDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string DeploymentId { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
{
|
||||
public static readonly List<DeploymentDto> Deployments = new()
|
||||
{
|
||||
new()
|
||||
success = true,
|
||||
message = result.Message,
|
||||
deployment = result.Deployment,
|
||||
}),
|
||||
DeploymentMutationStatus.Conflict => Results.Conflict(new
|
||||
{
|
||||
Id = "dep-001",
|
||||
ReleaseId = "rel-001",
|
||||
ReleaseName = "Platform Release",
|
||||
ReleaseVersion = "1.2.3",
|
||||
Environment = "production",
|
||||
Status = "completed",
|
||||
Strategy = "rolling",
|
||||
InitiatedBy = "deploy-bot",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-11T14:00:00Z"),
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"),
|
||||
TargetsTotal = 3,
|
||||
TargetsCompleted = 3,
|
||||
TargetsFailed = 0,
|
||||
Targets = new()
|
||||
{
|
||||
new() { Id = "tgt-001", Name = "prod-host-01", Status = "completed", Host = "10.0.1.10", StartedAt = DateTimeOffset.Parse("2026-01-11T14:00:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-11T14:10:00Z") },
|
||||
new() { Id = "tgt-002", Name = "prod-host-02", Status = "completed", Host = "10.0.1.11", StartedAt = DateTimeOffset.Parse("2026-01-11T14:10:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-11T14:20:00Z") },
|
||||
new() { Id = "tgt-003", Name = "prod-host-03", Status = "completed", Host = "10.0.1.12", StartedAt = DateTimeOffset.Parse("2026-01-11T14:20:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z") },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "dep-002",
|
||||
ReleaseId = "rel-003",
|
||||
ReleaseName = "Hotfix",
|
||||
ReleaseVersion = "1.2.4",
|
||||
Environment = "production",
|
||||
Status = "in_progress",
|
||||
Strategy = "rolling",
|
||||
InitiatedBy = "security-team",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-12T10:00:00Z"),
|
||||
TargetsTotal = 3,
|
||||
TargetsCompleted = 1,
|
||||
TargetsFailed = 0,
|
||||
Targets = new()
|
||||
{
|
||||
new() { Id = "tgt-004", Name = "prod-host-01", Status = "completed", Host = "10.0.1.10", StartedAt = DateTimeOffset.Parse("2026-01-12T10:00:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-12T10:08:00Z") },
|
||||
new() { Id = "tgt-005", Name = "prod-host-02", Status = "in_progress", Host = "10.0.1.11", StartedAt = DateTimeOffset.Parse("2026-01-12T10:08:00Z") },
|
||||
new() { Id = "tgt-006", Name = "prod-host-03", Status = "pending", Host = "10.0.1.12" },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "dep-003",
|
||||
ReleaseId = "rel-002",
|
||||
ReleaseName = "Platform Release",
|
||||
ReleaseVersion = "1.3.0-rc1",
|
||||
Environment = "staging",
|
||||
Status = "completed",
|
||||
Strategy = "blue_green",
|
||||
InitiatedBy = "ci-pipeline",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-11T12:00:00Z"),
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-11T12:15:00Z"),
|
||||
TargetsTotal = 2,
|
||||
TargetsCompleted = 2,
|
||||
TargetsFailed = 0,
|
||||
Targets = new()
|
||||
{
|
||||
new() { Id = "tgt-007", Name = "staging-blue", Status = "completed", Host = "10.0.2.10", StartedAt = DateTimeOffset.Parse("2026-01-11T12:00:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-11T12:15:00Z") },
|
||||
new() { Id = "tgt-008", Name = "staging-green", Status = "completed", Host = "10.0.2.11", StartedAt = DateTimeOffset.Parse("2026-01-11T12:00:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-11T12:15:00Z") },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "dep-004",
|
||||
ReleaseId = "rel-005",
|
||||
ReleaseName = "Platform Release",
|
||||
ReleaseVersion = "1.2.2",
|
||||
Environment = "production",
|
||||
Status = "failed",
|
||||
Strategy = "rolling",
|
||||
InitiatedBy = "deploy-bot",
|
||||
StartedAt = DateTimeOffset.Parse("2026-01-06T10:00:00Z"),
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-06T10:25:00Z"),
|
||||
TargetsTotal = 3,
|
||||
TargetsCompleted = 1,
|
||||
TargetsFailed = 2,
|
||||
Targets = new()
|
||||
{
|
||||
new() { Id = "tgt-009", Name = "prod-host-01", Status = "completed", Host = "10.0.1.10", StartedAt = DateTimeOffset.Parse("2026-01-06T10:00:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-06T10:10:00Z") },
|
||||
new() { Id = "tgt-010", Name = "prod-host-02", Status = "failed", Host = "10.0.1.11", StartedAt = DateTimeOffset.Parse("2026-01-06T10:10:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-06T10:20:00Z"), ErrorMessage = "Health check failed after deployment: HTTP 503 on /healthz" },
|
||||
new() { Id = "tgt-011", Name = "prod-host-03", Status = "failed", Host = "10.0.1.12", StartedAt = DateTimeOffset.Parse("2026-01-06T10:20:00Z"), CompletedAt = DateTimeOffset.Parse("2026-01-06T10:25:00Z"), ErrorMessage = "Container failed to start: OOM killed" },
|
||||
},
|
||||
},
|
||||
success = false,
|
||||
message = result.Message,
|
||||
}),
|
||||
_ => Results.NotFound(),
|
||||
};
|
||||
}
|
||||
|
||||
public static readonly Dictionary<string, List<DeploymentEventDto>> Events = new()
|
||||
private static string ResolveTenant(IStellaOpsTenantAccessor tenantAccessor, HttpContext context)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenantAccessor.TenantId))
|
||||
{
|
||||
["dep-001"] = new()
|
||||
return tenantAccessor.TenantId;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"A tenant is required for deployment compatibility operations on route '{context.Request.Path}'.");
|
||||
}
|
||||
|
||||
private static string? NormalizeStrategy(string? strategy)
|
||||
{
|
||||
return (strategy ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"rolling" => "rolling",
|
||||
"canary" => "canary",
|
||||
"blue_green" => "blue_green",
|
||||
"all_at_once" => "all_at_once",
|
||||
"recreate" => "all_at_once",
|
||||
"ab-release" => "blue_green",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> Csv(params string?[] values)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
new() { Id = "devt-001", DeploymentId = "dep-001", Type = "started", Message = "Deployment started with rolling strategy", Actor = "deploy-bot", Timestamp = DateTimeOffset.Parse("2026-01-11T14:00:00Z") },
|
||||
new() { Id = "devt-002", DeploymentId = "dep-001", Type = "target_completed", Message = "Target prod-host-01 deployed successfully", TargetId = "tgt-001", Timestamp = DateTimeOffset.Parse("2026-01-11T14:10:00Z") },
|
||||
new() { Id = "devt-003", DeploymentId = "dep-001", Type = "target_completed", Message = "Target prod-host-02 deployed successfully", TargetId = "tgt-002", Timestamp = DateTimeOffset.Parse("2026-01-11T14:20:00Z") },
|
||||
new() { Id = "devt-004", DeploymentId = "dep-001", Type = "target_completed", Message = "Target prod-host-03 deployed successfully", TargetId = "tgt-003", Timestamp = DateTimeOffset.Parse("2026-01-11T14:30:00Z") },
|
||||
new() { Id = "devt-005", DeploymentId = "dep-001", Type = "completed", Message = "Deployment completed successfully — all targets healthy", Actor = "deploy-bot", Timestamp = DateTimeOffset.Parse("2026-01-11T14:30:00Z") },
|
||||
},
|
||||
["dep-002"] = new()
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
new() { Id = "devt-006", DeploymentId = "dep-002", Type = "started", Message = "Hotfix deployment started with rolling strategy", Actor = "security-team", Timestamp = DateTimeOffset.Parse("2026-01-12T10:00:00Z") },
|
||||
new() { Id = "devt-007", DeploymentId = "dep-002", Type = "target_completed", Message = "Target prod-host-01 deployed successfully", TargetId = "tgt-004", Timestamp = DateTimeOffset.Parse("2026-01-12T10:08:00Z") },
|
||||
new() { Id = "devt-008", DeploymentId = "dep-002", Type = "target_started", Message = "Rolling to target prod-host-02", TargetId = "tgt-005", Timestamp = DateTimeOffset.Parse("2026-01-12T10:08:00Z") },
|
||||
},
|
||||
["dep-004"] = new()
|
||||
{
|
||||
new() { Id = "devt-009", DeploymentId = "dep-004", Type = "started", Message = "Deployment started with rolling strategy", Actor = "deploy-bot", Timestamp = DateTimeOffset.Parse("2026-01-06T10:00:00Z") },
|
||||
new() { Id = "devt-010", DeploymentId = "dep-004", Type = "target_completed", Message = "Target prod-host-01 deployed successfully", TargetId = "tgt-009", Timestamp = DateTimeOffset.Parse("2026-01-06T10:10:00Z") },
|
||||
new() { Id = "devt-011", DeploymentId = "dep-004", Type = "target_failed", Message = "Target prod-host-02 failed health check", TargetId = "tgt-010", Timestamp = DateTimeOffset.Parse("2026-01-06T10:20:00Z") },
|
||||
new() { Id = "devt-012", DeploymentId = "dep-004", Type = "target_failed", Message = "Target prod-host-03 container OOM killed", TargetId = "tgt-011", Timestamp = DateTimeOffset.Parse("2026-01-06T10:25:00Z") },
|
||||
new() { Id = "devt-013", DeploymentId = "dep-004", Type = "failed", Message = "Deployment failed — 2 of 3 targets unhealthy", Actor = "deploy-bot", Timestamp = DateTimeOffset.Parse("2026-01-06T10:25:00Z") },
|
||||
},
|
||||
set.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private static DeploymentSummaryDto ToSummary(DeploymentDto deployment)
|
||||
{
|
||||
return new DeploymentSummaryDto
|
||||
{
|
||||
Id = deployment.Id,
|
||||
ReleaseId = deployment.ReleaseId,
|
||||
ReleaseName = deployment.ReleaseName,
|
||||
ReleaseVersion = deployment.ReleaseVersion,
|
||||
EnvironmentId = deployment.EnvironmentId,
|
||||
EnvironmentName = deployment.EnvironmentName,
|
||||
Status = deployment.Status,
|
||||
Strategy = deployment.Strategy,
|
||||
Progress = deployment.Progress,
|
||||
StartedAt = deployment.StartedAt,
|
||||
CompletedAt = deployment.CompletedAt,
|
||||
InitiatedBy = deployment.InitiatedBy,
|
||||
TargetCount = deployment.TargetCount,
|
||||
CompletedTargets = deployment.CompletedTargets,
|
||||
FailedTargets = deployment.FailedTargets,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ builder.Services.AddJobEngineInfrastructure(builder.Configuration);
|
||||
builder.Services.AddSingleton<TenantResolver>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ReleasePromotionDecisionStore>();
|
||||
builder.Services.AddDeploymentCompatibilityStore();
|
||||
|
||||
// Register streaming options and coordinators
|
||||
builder.Services.Configure<StreamOptions>(builder.Configuration.GetSection(StreamOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
public sealed record CreateDeploymentRequest
|
||||
{
|
||||
public string ReleaseId { get; init; } = string.Empty;
|
||||
public string EnvironmentId { get; init; } = string.Empty;
|
||||
public string? EnvironmentName { get; init; }
|
||||
public string Strategy { get; init; } = "rolling";
|
||||
public JsonElement? StrategyConfig { get; init; }
|
||||
public string? PackageType { get; init; }
|
||||
public string? PackageRefId { get; init; }
|
||||
public string? PackageRefName { get; init; }
|
||||
public IReadOnlyList<PromotionStageDto> PromotionStages { get; init; } = Array.Empty<PromotionStageDto>();
|
||||
}
|
||||
|
||||
public sealed record PromotionStageDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string EnvironmentId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record class DeploymentSummaryDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string EnvironmentId { get; init; }
|
||||
public required string EnvironmentName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Strategy { get; init; }
|
||||
public int Progress { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string InitiatedBy { get; init; } = string.Empty;
|
||||
public int TargetCount { get; init; }
|
||||
public int CompletedTargets { get; init; }
|
||||
public int FailedTargets { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentDto : DeploymentSummaryDto
|
||||
{
|
||||
public List<DeploymentTargetDto> Targets { get; init; } = [];
|
||||
public string? CurrentStep { get; init; }
|
||||
public bool CanPause { get; init; }
|
||||
public bool CanResume { get; init; }
|
||||
public bool CanCancel { get; init; }
|
||||
public bool CanRollback { get; init; }
|
||||
public JsonElement? StrategyConfig { get; init; }
|
||||
public IReadOnlyList<PromotionStageDto> PromotionStages { get; init; } = Array.Empty<PromotionStageDto>();
|
||||
public string? PackageType { get; init; }
|
||||
public string? PackageRefId { get; init; }
|
||||
public string? PackageRefName { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentTargetDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int Progress { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public int? Duration { get; init; }
|
||||
public string AgentId { get; init; } = string.Empty;
|
||||
public string? Error { get; init; }
|
||||
public string? PreviousVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentEventDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
public string? TargetName { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentLogEntryDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public required string Level { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentMetricsDto
|
||||
{
|
||||
public int TotalDuration { get; init; }
|
||||
public int AverageTargetDuration { get; init; }
|
||||
public double SuccessRate { get; init; }
|
||||
public int RollbackCount { get; init; }
|
||||
public int ImagesPulled { get; init; }
|
||||
public int ContainersStarted { get; init; }
|
||||
public int ContainersRemoved { get; init; }
|
||||
public int HealthChecksPerformed { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DeploymentCompatibilityState(
|
||||
DeploymentDto Deployment,
|
||||
List<DeploymentLogEntryDto> Logs,
|
||||
List<DeploymentEventDto> Events,
|
||||
DeploymentMetricsDto Metrics);
|
||||
|
||||
public enum DeploymentMutationStatus
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
public sealed record DeploymentMutationResult(
|
||||
DeploymentMutationStatus Status,
|
||||
string Message,
|
||||
DeploymentDto? Deployment);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.JobEngine.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
public static class DeploymentCompatibilityServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddDeploymentCompatibilityStore(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<InMemoryDeploymentCompatibilityStore>();
|
||||
services.AddSingleton<IDeploymentCompatibilityStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<JobEngineServiceOptions>>().Value;
|
||||
return string.IsNullOrWhiteSpace(options.Database.ConnectionString)
|
||||
? sp.GetRequiredService<InMemoryDeploymentCompatibilityStore>()
|
||||
: ActivatorUtilities.CreateInstance<PostgresDeploymentCompatibilityStore>(sp);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
internal static class DeploymentCompatibilityStateFactory
|
||||
{
|
||||
public static IReadOnlyList<DeploymentCompatibilityState> CreateSeedStates()
|
||||
=> [
|
||||
CreateSeedState("dep-001", "rel-001", "platform-release", "2026.04.01", "env-prod", "Production", "completed", "rolling", DateTimeOffset.Parse("2026-04-01T09:00:00Z"), 3, null, 1),
|
||||
CreateSeedState("dep-002", "rel-002", "checkout-api", "2026.04.02", "env-staging", "Staging", "running", "canary", DateTimeOffset.Parse("2026-04-02T12:15:00Z"), 3, null, 4),
|
||||
CreateSeedState("dep-003", "rel-003", "worker-service", "2026.04.03", "env-dev", "Development", "failed", "all_at_once", DateTimeOffset.Parse("2026-04-03T08:30:00Z"), 4, 2, 7),
|
||||
CreateSeedState("dep-004", "rel-004", "gateway-hotfix", "hf-2026.04.04", "env-stage-eu", "EU Stage", "paused", "blue_green", DateTimeOffset.Parse("2026-04-04T06:00:00Z"), 4, 0, 10),
|
||||
];
|
||||
|
||||
public static DeploymentCompatibilityState CreateState(
|
||||
CreateDeploymentRequest request,
|
||||
string actor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var id = $"dep-{Guid.NewGuid():N}"[..16];
|
||||
var envName = string.IsNullOrWhiteSpace(request.EnvironmentName)
|
||||
? Pretty(request.EnvironmentId)
|
||||
: request.EnvironmentName!;
|
||||
var targets = CreateTargets(
|
||||
request.EnvironmentId,
|
||||
request.Strategy == "all_at_once" ? 4 : 3,
|
||||
failedIndex: null,
|
||||
offset: 20,
|
||||
baseTime: now.AddMinutes(-4));
|
||||
|
||||
var deployment = Recalculate(new DeploymentDto
|
||||
{
|
||||
Id = id,
|
||||
ReleaseId = request.ReleaseId,
|
||||
ReleaseName = request.PackageRefName ?? request.ReleaseId,
|
||||
ReleaseVersion = request.PackageRefName ?? request.PackageRefId ?? "version-1",
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
EnvironmentName = envName,
|
||||
Status = "pending",
|
||||
Strategy = request.Strategy,
|
||||
StartedAt = now,
|
||||
InitiatedBy = actor,
|
||||
Targets = targets,
|
||||
CurrentStep = "Queued for rollout",
|
||||
CanCancel = true,
|
||||
StrategyConfig = request.StrategyConfig,
|
||||
PromotionStages = request.PromotionStages,
|
||||
PackageType = request.PackageType,
|
||||
PackageRefId = request.PackageRefId,
|
||||
PackageRefName = request.PackageRefName,
|
||||
});
|
||||
|
||||
return new DeploymentCompatibilityState(
|
||||
deployment,
|
||||
[
|
||||
new DeploymentLogEntryDto
|
||||
{
|
||||
Timestamp = now,
|
||||
Level = "info",
|
||||
Source = "jobengine",
|
||||
Message = $"Deployment {id} created for {request.EnvironmentId}.",
|
||||
},
|
||||
],
|
||||
[
|
||||
new DeploymentEventDto
|
||||
{
|
||||
Id = $"evt-{Guid.NewGuid():N}"[..16],
|
||||
Type = "started",
|
||||
Message = $"Deployment {id} queued.",
|
||||
Timestamp = now,
|
||||
},
|
||||
],
|
||||
new DeploymentMetricsDto());
|
||||
}
|
||||
|
||||
public static DeploymentCompatibilityState Transition(
|
||||
DeploymentCompatibilityState current,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var nextDeployment = Recalculate(current.Deployment with
|
||||
{
|
||||
Status = nextStatus,
|
||||
CompletedAt = complete ? now : current.Deployment.CompletedAt,
|
||||
CurrentStep = nextStatus switch
|
||||
{
|
||||
"paused" => "Deployment paused",
|
||||
"running" => "Deployment resumed",
|
||||
"cancelled" => "Deployment cancelled",
|
||||
"rolling_back" => "Rollback started",
|
||||
_ => current.Deployment.CurrentStep,
|
||||
},
|
||||
});
|
||||
|
||||
var nextMetrics = nextStatus == "rolling_back"
|
||||
? current.Metrics with { RollbackCount = current.Metrics.RollbackCount + 1 }
|
||||
: current.Metrics;
|
||||
|
||||
var logs = current.Logs
|
||||
.Append(new DeploymentLogEntryDto
|
||||
{
|
||||
Timestamp = now,
|
||||
Level = "info",
|
||||
Source = "jobengine",
|
||||
Message = message,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var events = current.Events
|
||||
.Append(new DeploymentEventDto
|
||||
{
|
||||
Id = $"evt-{Guid.NewGuid():N}"[..16],
|
||||
Type = eventType,
|
||||
Message = message,
|
||||
Timestamp = now,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return current with
|
||||
{
|
||||
Deployment = nextDeployment,
|
||||
Logs = logs,
|
||||
Events = events,
|
||||
Metrics = nextMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
public static DeploymentCompatibilityState Retry(
|
||||
DeploymentCompatibilityState current,
|
||||
string targetId,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var target = current.Deployment.Targets.First(t => string.Equals(t.Id, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var targets = current.Deployment.Targets
|
||||
.Select(item => item.Id == targetId
|
||||
? item with
|
||||
{
|
||||
Status = "pending",
|
||||
Progress = 0,
|
||||
StartedAt = null,
|
||||
CompletedAt = null,
|
||||
Duration = null,
|
||||
Error = null,
|
||||
}
|
||||
: item)
|
||||
.ToList();
|
||||
|
||||
var nextDeployment = Recalculate(current.Deployment with
|
||||
{
|
||||
Status = "running",
|
||||
CompletedAt = null,
|
||||
CurrentStep = $"Retrying {target.Name}",
|
||||
Targets = targets,
|
||||
});
|
||||
|
||||
var logs = current.Logs
|
||||
.Append(new DeploymentLogEntryDto
|
||||
{
|
||||
Timestamp = now,
|
||||
Level = "warn",
|
||||
Source = "jobengine",
|
||||
TargetId = targetId,
|
||||
Message = $"Retry requested for {target.Name}.",
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var events = current.Events
|
||||
.Append(new DeploymentEventDto
|
||||
{
|
||||
Id = $"evt-{Guid.NewGuid():N}"[..16],
|
||||
Type = "target_started",
|
||||
TargetId = targetId,
|
||||
TargetName = target.Name,
|
||||
Message = $"Retry started for {target.Name}.",
|
||||
Timestamp = now,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return current with
|
||||
{
|
||||
Deployment = nextDeployment,
|
||||
Logs = logs,
|
||||
Events = events,
|
||||
};
|
||||
}
|
||||
|
||||
private static DeploymentCompatibilityState CreateSeedState(
|
||||
string id,
|
||||
string releaseId,
|
||||
string releaseName,
|
||||
string releaseVersion,
|
||||
string environmentId,
|
||||
string environmentName,
|
||||
string status,
|
||||
string strategy,
|
||||
DateTimeOffset startedAt,
|
||||
int targetCount,
|
||||
int? failedIndex,
|
||||
int offset)
|
||||
{
|
||||
var targets = CreateTargets(environmentId, targetCount, failedIndex, offset, startedAt.AddMinutes(-targetCount * 4));
|
||||
DateTimeOffset? completedAt = status is "completed" or "failed" ? startedAt.AddMinutes(18) : null;
|
||||
var deployment = Recalculate(new DeploymentDto
|
||||
{
|
||||
Id = id,
|
||||
ReleaseId = releaseId,
|
||||
ReleaseName = releaseName,
|
||||
ReleaseVersion = releaseVersion,
|
||||
EnvironmentId = environmentId,
|
||||
EnvironmentName = environmentName,
|
||||
Status = status,
|
||||
Strategy = strategy,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
InitiatedBy = "deploy-bot",
|
||||
Targets = targets,
|
||||
CurrentStep = status switch
|
||||
{
|
||||
"running" => $"Deploying {targets.First(t => t.Status == "running").Name}",
|
||||
"paused" => "Awaiting operator resume",
|
||||
"failed" => $"Target {targets.First(t => t.Status == "failed").Name} failed",
|
||||
_ => null,
|
||||
},
|
||||
});
|
||||
|
||||
var logs = new List<DeploymentLogEntryDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Timestamp = startedAt,
|
||||
Level = "info",
|
||||
Source = "jobengine",
|
||||
Message = $"Deployment {id} started.",
|
||||
},
|
||||
};
|
||||
logs.AddRange(targets.Select(target => new DeploymentLogEntryDto
|
||||
{
|
||||
Timestamp = target.StartedAt ?? startedAt,
|
||||
Level = target.Status == "failed" ? "error" : "info",
|
||||
Source = target.AgentId,
|
||||
TargetId = target.Id,
|
||||
Message = target.Status == "failed"
|
||||
? $"{target.Name} failed health checks."
|
||||
: $"{target.Name} progressed to {target.Status}.",
|
||||
}));
|
||||
|
||||
var events = new List<DeploymentEventDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = $"evt-{id}-start",
|
||||
Type = "started",
|
||||
Message = $"Deployment {id} started.",
|
||||
Timestamp = startedAt,
|
||||
},
|
||||
};
|
||||
events.AddRange(targets.Select(target => new DeploymentEventDto
|
||||
{
|
||||
Id = $"evt-{id}-{target.Id}",
|
||||
Type = target.Status == "failed"
|
||||
? "target_failed"
|
||||
: target.Status == "running"
|
||||
? "target_started"
|
||||
: "target_completed",
|
||||
TargetId = target.Id,
|
||||
TargetName = target.Name,
|
||||
Message = target.Status == "failed"
|
||||
? $"{target.Name} failed."
|
||||
: target.Status == "running"
|
||||
? $"{target.Name} is running."
|
||||
: $"{target.Name} completed.",
|
||||
Timestamp = target.StartedAt ?? startedAt,
|
||||
}));
|
||||
|
||||
var completedDurations = targets.Where(target => target.Duration.HasValue).Select(target => target.Duration!.Value).ToArray();
|
||||
var metrics = new DeploymentMetricsDto
|
||||
{
|
||||
TotalDuration = completedAt.HasValue ? (int)(completedAt.Value - startedAt).TotalMilliseconds : 0,
|
||||
AverageTargetDuration = completedDurations.Length == 0 ? 0 : (int)completedDurations.Average(),
|
||||
SuccessRate = Math.Round(targets.Count(target => target.Status == "completed") / (double)targetCount * 100, 2),
|
||||
ImagesPulled = targetCount,
|
||||
ContainersStarted = targets.Count(target => target.Status is "completed" or "running"),
|
||||
ContainersRemoved = targets.Count(target => target.Status == "completed"),
|
||||
HealthChecksPerformed = targetCount * 2,
|
||||
};
|
||||
|
||||
return new DeploymentCompatibilityState(deployment, logs, events, metrics);
|
||||
}
|
||||
|
||||
private static List<DeploymentTargetDto> CreateTargets(
|
||||
string environmentId,
|
||||
int count,
|
||||
int? failedIndex,
|
||||
int offset,
|
||||
DateTimeOffset baseTime)
|
||||
{
|
||||
var items = new List<DeploymentTargetDto>(count);
|
||||
var prefix = environmentId.Contains("prod", StringComparison.OrdinalIgnoreCase) ? "prod" : "node";
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var failed = failedIndex.HasValue && index == failedIndex.Value;
|
||||
var running = !failedIndex.HasValue && index == count - 1;
|
||||
var status = failed ? "failed" : running ? "running" : "completed";
|
||||
var startedAt = baseTime.AddMinutes(index * 3);
|
||||
DateTimeOffset? completedAt = status == "completed" ? startedAt.AddMinutes(2) : null;
|
||||
items.Add(new DeploymentTargetDto
|
||||
{
|
||||
Id = $"tgt-{offset + index:000}",
|
||||
Name = $"{prefix}-{offset + index:00}",
|
||||
Type = index % 2 == 0 ? "docker_host" : "compose_host",
|
||||
Status = status,
|
||||
Progress = status == "completed" ? 100 : status == "running" ? 65 : 45,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Duration = completedAt.HasValue ? (int)(completedAt.Value - startedAt).TotalMilliseconds : null,
|
||||
AgentId = $"agent-{offset + index:000}",
|
||||
Error = status == "failed" ? "Health check failed" : null,
|
||||
PreviousVersion = "2026.03.31",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
internal static DeploymentDto Recalculate(DeploymentDto deployment)
|
||||
{
|
||||
var totalTargets = deployment.Targets.Count;
|
||||
var completedTargets = deployment.Targets.Count(target => target.Status == "completed");
|
||||
var failedTargets = deployment.Targets.Count(target => target.Status == "failed");
|
||||
var progress = totalTargets == 0
|
||||
? 0
|
||||
: (int)Math.Round(deployment.Targets.Sum(target => target.Progress) / (double)totalTargets);
|
||||
|
||||
return deployment with
|
||||
{
|
||||
TargetCount = totalTargets,
|
||||
CompletedTargets = completedTargets,
|
||||
FailedTargets = failedTargets,
|
||||
Progress = progress,
|
||||
CanPause = deployment.Status == "running",
|
||||
CanResume = deployment.Status == "paused",
|
||||
CanCancel = deployment.Status is "pending" or "running" or "paused",
|
||||
CanRollback = deployment.Status is "completed" or "failed" or "running" or "paused",
|
||||
};
|
||||
}
|
||||
|
||||
private static string Pretty(string value)
|
||||
{
|
||||
return string.Join(
|
||||
' ',
|
||||
value.Split(['-', '_'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
public interface IDeploymentCompatibilityStore
|
||||
{
|
||||
Task<IReadOnlyList<DeploymentDto>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentDto?> GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DeploymentLogEntryDto>?> GetLogsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string? targetId,
|
||||
string? level,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DeploymentEventDto>?> GetEventsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMetricsDto?> GetMetricsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentDto> CreateAsync(
|
||||
string tenantId,
|
||||
CreateDeploymentRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMutationResult> TransitionAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> allowedStatuses,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DeploymentMutationResult> RetryAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
public sealed class InMemoryDeploymentCompatibilityStore : IDeploymentCompatibilityStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, DeploymentCompatibilityState>> _tenants = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryDeploymentCompatibilityStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeploymentDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult<IReadOnlyList<DeploymentDto>>(states.Values
|
||||
.Select(state => state.Deployment)
|
||||
.OrderByDescending(item => item.StartedAt)
|
||||
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public Task<DeploymentDto?> GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult(states.TryGetValue(deploymentId, out var state) ? state.Deployment : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeploymentLogEntryDto>?> GetLogsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string? targetId,
|
||||
string? level,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
if (!states.TryGetValue(deploymentId, out var state))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<DeploymentLogEntryDto>?>(null);
|
||||
}
|
||||
|
||||
IEnumerable<DeploymentLogEntryDto> logs = state.Logs;
|
||||
if (!string.IsNullOrWhiteSpace(targetId))
|
||||
{
|
||||
logs = logs.Where(item => string.Equals(item.TargetId, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(level))
|
||||
{
|
||||
logs = logs.Where(item => string.Equals(item.Level, level, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DeploymentLogEntryDto>?>(logs
|
||||
.TakeLast(Math.Clamp(limit ?? 500, 1, 5000))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeploymentEventDto>?> GetEventsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult<IReadOnlyList<DeploymentEventDto>?>(states.TryGetValue(deploymentId, out var state)
|
||||
? state.Events.OrderBy(item => item.Timestamp).ToList()
|
||||
: null);
|
||||
}
|
||||
|
||||
public Task<DeploymentMetricsDto?> GetMetricsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
return Task.FromResult(states.TryGetValue(deploymentId, out var state) ? state.Metrics : null);
|
||||
}
|
||||
|
||||
public Task<DeploymentDto> CreateAsync(
|
||||
string tenantId,
|
||||
CreateDeploymentRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
var state = DeploymentCompatibilityStateFactory.CreateState(request, actor, _timeProvider);
|
||||
states[state.Deployment.Id] = state;
|
||||
return Task.FromResult(state.Deployment);
|
||||
}
|
||||
|
||||
public Task<DeploymentMutationResult> TransitionAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> allowedStatuses,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
if (!states.TryGetValue(deploymentId, out var current))
|
||||
{
|
||||
return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null));
|
||||
}
|
||||
|
||||
if (!allowedStatuses.Contains(current.Deployment.Status, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Conflict,
|
||||
$"Deployment {deploymentId} cannot transition from '{current.Deployment.Status}' to '{nextStatus}'.",
|
||||
null));
|
||||
}
|
||||
|
||||
var next = DeploymentCompatibilityStateFactory.Transition(current, nextStatus, eventType, message, complete, _timeProvider);
|
||||
states[deploymentId] = next;
|
||||
return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.Success, message, next.Deployment));
|
||||
}
|
||||
|
||||
public Task<DeploymentMutationResult> RetryAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var states = GetOrSeedTenantState(tenantId);
|
||||
if (!states.TryGetValue(deploymentId, out var current))
|
||||
{
|
||||
return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null));
|
||||
}
|
||||
|
||||
var target = current.Deployment.Targets.FirstOrDefault(item => string.Equals(item.Id, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
if (target is null)
|
||||
{
|
||||
return Task.FromResult(new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null));
|
||||
}
|
||||
|
||||
if (target.Status is not ("failed" or "skipped"))
|
||||
{
|
||||
return Task.FromResult(new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Conflict,
|
||||
$"Target {targetId} is not in a retryable state.",
|
||||
null));
|
||||
}
|
||||
|
||||
var next = DeploymentCompatibilityStateFactory.Retry(current, targetId, _timeProvider);
|
||||
states[deploymentId] = next;
|
||||
return Task.FromResult(new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Success,
|
||||
$"Retry initiated for {target.Name}.",
|
||||
next.Deployment));
|
||||
}
|
||||
|
||||
private ConcurrentDictionary<string, DeploymentCompatibilityState> GetOrSeedTenantState(string tenantId)
|
||||
{
|
||||
return _tenants.GetOrAdd(tenantId, _ =>
|
||||
{
|
||||
var states = new ConcurrentDictionary<string, DeploymentCompatibilityState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var seed in DeploymentCompatibilityStateFactory.CreateSeedStates())
|
||||
{
|
||||
states[seed.Deployment.Id] = seed;
|
||||
}
|
||||
|
||||
return states;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.JobEngine.Infrastructure.Options;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
public sealed class PostgresDeploymentCompatibilityStore : IDeploymentCompatibilityStore, IAsyncDisposable
|
||||
{
|
||||
private const string QualifiedTableName = "\"orchestrator\".compatibility_deployments";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresDeploymentCompatibilityStore(
|
||||
IOptions<JobEngineServiceOptions> options,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var databaseOptions = options.Value.Database;
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(databaseOptions.ConnectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-jobengine-compatibility-store",
|
||||
Pooling = databaseOptions.EnablePooling,
|
||||
MinPoolSize = databaseOptions.MinPoolSize,
|
||||
MaxPoolSize = databaseOptions.MaxPoolSize,
|
||||
};
|
||||
|
||||
_dataSource = new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
||||
_commandTimeoutSeconds = Math.Max(databaseOptions.CommandTimeoutSeconds, 1);
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
=> _dataSource.DisposeAsync();
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSeedDataAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql =
|
||||
$"""
|
||||
SELECT deployment_json
|
||||
FROM {QualifiedTableName}
|
||||
WHERE tenant_id = @tenant
|
||||
ORDER BY started_at DESC, deployment_id
|
||||
""";
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenantId);
|
||||
|
||||
var items = new List<DeploymentDto>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(Deserialize<DeploymentDto>(reader.GetString(0)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<DeploymentDto?> GetAsync(string tenantId, string deploymentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, deploymentId, cancellationToken).ConfigureAwait(false);
|
||||
return state?.Deployment;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentLogEntryDto>?> GetLogsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string? targetId,
|
||||
string? level,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, deploymentId, cancellationToken).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IEnumerable<DeploymentLogEntryDto> logs = state.Logs;
|
||||
if (!string.IsNullOrWhiteSpace(targetId))
|
||||
{
|
||||
logs = logs.Where(item => string.Equals(item.TargetId, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(level))
|
||||
{
|
||||
logs = logs.Where(item => string.Equals(item.Level, level, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return logs.TakeLast(Math.Clamp(limit ?? 500, 1, 5000)).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentEventDto>?> GetEventsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, deploymentId, cancellationToken).ConfigureAwait(false);
|
||||
return state?.Events.OrderBy(item => item.Timestamp).ToList();
|
||||
}
|
||||
|
||||
public async Task<DeploymentMetricsDto?> GetMetricsAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, deploymentId, cancellationToken).ConfigureAwait(false);
|
||||
return state?.Metrics;
|
||||
}
|
||||
|
||||
public async Task<DeploymentDto> CreateAsync(
|
||||
string tenantId,
|
||||
CreateDeploymentRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSeedDataAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var state = DeploymentCompatibilityStateFactory.CreateState(request, actor, _timeProvider);
|
||||
await UpsertStateAsync(connection, transaction: null, tenantId, state, cancellationToken).ConfigureAwait(false);
|
||||
return state.Deployment;
|
||||
}
|
||||
|
||||
public async Task<DeploymentMutationResult> TransitionAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
IReadOnlyCollection<string> allowedStatuses,
|
||||
string nextStatus,
|
||||
string eventType,
|
||||
string message,
|
||||
bool complete,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSeedDataAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var current = await LoadStateAsync(connection, transaction, tenantId, deploymentId, forUpdate: true, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null);
|
||||
}
|
||||
|
||||
if (!allowedStatuses.Contains(current.Deployment.Status, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Conflict,
|
||||
$"Deployment {deploymentId} cannot transition from '{current.Deployment.Status}' to '{nextStatus}'.",
|
||||
null);
|
||||
}
|
||||
|
||||
var next = DeploymentCompatibilityStateFactory.Transition(current, nextStatus, eventType, message, complete, _timeProvider);
|
||||
await UpsertStateAsync(connection, transaction, tenantId, next, cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(DeploymentMutationStatus.Success, message, next.Deployment);
|
||||
}
|
||||
|
||||
public async Task<DeploymentMutationResult> RetryAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSeedDataAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var current = await LoadStateAsync(connection, transaction, tenantId, deploymentId, forUpdate: true, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null);
|
||||
}
|
||||
|
||||
var target = current.Deployment.Targets.FirstOrDefault(item => string.Equals(item.Id, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
if (target is null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(DeploymentMutationStatus.NotFound, string.Empty, null);
|
||||
}
|
||||
|
||||
if (target.Status is not ("failed" or "skipped"))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Conflict,
|
||||
$"Target {targetId} is not in a retryable state.",
|
||||
null);
|
||||
}
|
||||
|
||||
var next = DeploymentCompatibilityStateFactory.Retry(current, targetId, _timeProvider);
|
||||
await UpsertStateAsync(connection, transaction, tenantId, next, cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new DeploymentMutationResult(
|
||||
DeploymentMutationStatus.Success,
|
||||
$"Retry initiated for {target.Name}.",
|
||||
next.Deployment);
|
||||
}
|
||||
|
||||
private async Task<DeploymentCompatibilityState?> GetStateAsync(
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSeedDataAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return await LoadStateAsync(connection, transaction: null, tenantId, deploymentId, forUpdate: false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureSeedDataAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var countSql = $"SELECT COUNT(*) FROM {QualifiedTableName} WHERE tenant_id = @tenant";
|
||||
await using var countCommand = CreateCommand(countSql, connection);
|
||||
countCommand.Parameters.AddWithValue("tenant", tenantId);
|
||||
|
||||
var existing = (long)(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
|
||||
if (existing > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var seed in DeploymentCompatibilityStateFactory.CreateSeedStates())
|
||||
{
|
||||
await UpsertStateAsync(connection, transaction, tenantId, seed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<DeploymentCompatibilityState?> LoadStateAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
string tenantId,
|
||||
string deploymentId,
|
||||
bool forUpdate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sql =
|
||||
$"""
|
||||
SELECT deployment_json, logs_json, events_json, metrics_json
|
||||
FROM {QualifiedTableName}
|
||||
WHERE tenant_id = @tenant AND deployment_id = @deploymentId
|
||||
{(forUpdate ? "FOR UPDATE" : string.Empty)}
|
||||
""";
|
||||
await using var command = CreateCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("tenant", tenantId);
|
||||
command.Parameters.AddWithValue("deploymentId", deploymentId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DeploymentCompatibilityState(
|
||||
Deserialize<DeploymentDto>(reader.GetString(0)),
|
||||
Deserialize<List<DeploymentLogEntryDto>>(reader.GetString(1)),
|
||||
Deserialize<List<DeploymentEventDto>>(reader.GetString(2)),
|
||||
Deserialize<DeploymentMetricsDto>(reader.GetString(3)));
|
||||
}
|
||||
|
||||
private async Task UpsertStateAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
string tenantId,
|
||||
DeploymentCompatibilityState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql =
|
||||
$"""
|
||||
INSERT INTO {QualifiedTableName}
|
||||
(tenant_id, deployment_id, started_at, created_at, updated_at, deployment_json, logs_json, events_json, metrics_json)
|
||||
VALUES
|
||||
(@tenant, @deploymentId, @startedAt, @createdAt, @updatedAt, CAST(@deploymentJson AS jsonb), CAST(@logsJson AS jsonb), CAST(@eventsJson AS jsonb), CAST(@metricsJson AS jsonb))
|
||||
ON CONFLICT (tenant_id, deployment_id) DO UPDATE
|
||||
SET started_at = EXCLUDED.started_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deployment_json = EXCLUDED.deployment_json,
|
||||
logs_json = EXCLUDED.logs_json,
|
||||
events_json = EXCLUDED.events_json,
|
||||
metrics_json = EXCLUDED.metrics_json
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("tenant", tenantId);
|
||||
command.Parameters.AddWithValue("deploymentId", state.Deployment.Id);
|
||||
command.Parameters.AddWithValue("startedAt", state.Deployment.StartedAt);
|
||||
command.Parameters.AddWithValue("createdAt", state.Deployment.StartedAt);
|
||||
command.Parameters.AddWithValue("updatedAt", now);
|
||||
command.Parameters.AddWithValue("deploymentJson", JsonSerializer.Serialize(state.Deployment, SerializerOptions));
|
||||
command.Parameters.AddWithValue("logsJson", JsonSerializer.Serialize(state.Logs, SerializerOptions));
|
||||
command.Parameters.AddWithValue("eventsJson", JsonSerializer.Serialize(state.Events, SerializerOptions));
|
||||
command.Parameters.AddWithValue("metricsJson", JsonSerializer.Serialize(state.Metrics, SerializerOptions));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dataSource.OpenConnectionAsync(cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
private NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction? transaction = null)
|
||||
{
|
||||
return new NpgsqlCommand(sql, connection, transaction)
|
||||
{
|
||||
CommandTimeout = _commandTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize deployment compatibility payload for {typeof(T).Name}.");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the deployment compatibility PostgreSQL datasource path for runtime attribution and pooling. |
|
||||
| SPRINT_20260323_001-TASK-002 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: deployment monitoring compatibility endpoints under `/api/v1/release-orchestrator/deployments/*` were verified as implemented and reachable. |
|
||||
| SPRINT_20260323_001-TASK-003 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: evidence compatibility endpoints now verify hashes against deterministic raw payloads and export stable offline bundles. |
|
||||
| SPRINT_20260323_001-TASK-005 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: dashboard approval/rejection endpoints now persist in-memory promotion decisions per app instance for Console compatibility flows. |
|
||||
|
||||
@@ -107,6 +107,7 @@ if (storageSection.Exists())
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
builder.Services.AddSingleton<IPolicySimulationMetricsProvider, PolicySimulationMetricsProvider>();
|
||||
builder.Services.AddSingleton<IPolicySimulationMetricsRecorder>(static sp => (IPolicySimulationMetricsRecorder)sp.GetRequiredService<IPolicySimulationMetricsProvider>());
|
||||
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the scheduler graph-job event Valkey client construction path. |
|
||||
| QA-SCHED-VERIFY-002 | DONE | `scheduler-graph-job-dtos` verified (run-001 Tier 0/1/2 pass); scheduler verification batch completed with `QA-SCHED-VERIFY-003` terminalized as `not_implemented` (run-001 Tier 0 evidence). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scheduler/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
@@ -63,10 +64,10 @@ public sealed class ScheduleRepository : RepositoryBase<SchedulerDataSource>, IS
|
||||
AddParameter(command, "cron_expression", schedule.CronExpression);
|
||||
AddParameter(command, "timezone", schedule.Timezone);
|
||||
AddParameter(command, "mode", schedule.Mode.ToString().ToLowerInvariant());
|
||||
AddParameter(command, "selection", JsonSerializer.Serialize(schedule.Selection, _serializer));
|
||||
AddParameter(command, "only_if", JsonSerializer.Serialize(schedule.OnlyIf, _serializer));
|
||||
AddParameter(command, "notify", JsonSerializer.Serialize(schedule.Notify, _serializer));
|
||||
AddParameter(command, "limits", JsonSerializer.Serialize(schedule.Limits, _serializer));
|
||||
AddJsonbParameter(command, "selection", JsonSerializer.Serialize(schedule.Selection, _serializer));
|
||||
AddJsonbParameter(command, "only_if", JsonSerializer.Serialize(schedule.OnlyIf, _serializer));
|
||||
AddJsonbParameter(command, "notify", JsonSerializer.Serialize(schedule.Notify, _serializer));
|
||||
AddJsonbParameter(command, "limits", JsonSerializer.Serialize(schedule.Limits, _serializer));
|
||||
AddTextArrayParameter(command, "subscribers", schedule.Subscribers.ToArray());
|
||||
AddParameter(command, "created_at", schedule.CreatedAt);
|
||||
AddParameter(command, "created_by", schedule.CreatedBy);
|
||||
@@ -172,7 +173,7 @@ public sealed class ScheduleRepository : RepositoryBase<SchedulerDataSource>, IS
|
||||
JsonSerializer.Deserialize<ScheduleOnlyIf>(reader.GetString(reader.GetOrdinal("only_if")), _serializer)!,
|
||||
JsonSerializer.Deserialize<ScheduleNotify>(reader.GetString(reader.GetOrdinal("notify")), _serializer)!,
|
||||
JsonSerializer.Deserialize<ScheduleLimits>(reader.GetString(reader.GetOrdinal("limits")), _serializer)!,
|
||||
JsonSerializer.Deserialize<System.Collections.Immutable.ImmutableArray<string>>(reader.GetString(reader.GetOrdinal("subscribers")), _serializer),
|
||||
reader.GetFieldValue<string[]>(reader.GetOrdinal("subscribers")).ToImmutableArray(),
|
||||
DateTime.SpecifyKind(reader.GetDateTime(reader.GetOrdinal("created_at")), DateTimeKind.Utc),
|
||||
reader.GetString(reader.GetOrdinal("created_by")),
|
||||
DateTime.SpecifyKind(reader.GetDateTime(reader.GetOrdinal("updated_at")), DateTimeKind.Utc),
|
||||
|
||||
Reference in New Issue
Block a user