Complete release compatibility and host inventory sprints

Signed-off-by: master <>
This commit is contained in:
master
2026-03-31 23:53:45 +03:00
parent b6bf113b99
commit f96c6cb9ed
33 changed files with 2322 additions and 362 deletions

View File

@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.JobEngine.WebService;
using StellaOps.JobEngine.WebService.Endpoints;
using StellaOps.JobEngine.WebService.Services;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace StellaOps.JobEngine.Tests.ControlPlane;
public sealed class ReleaseCompatibilityEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeploymentEndpoints_ReturnSuccessForExpectedLifecycleRoutes()
{
await using var app = await CreateTestAppAsync();
using var client = app.GetTestClient();
var listResponse = await client.GetAsync("/api/v1/release-orchestrator/deployments", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var detailResponse = await client.GetAsync("/api/v1/release-orchestrator/deployments/dep-002", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
var routes = new[]
{
"/api/v1/release-orchestrator/deployments/dep-002/logs",
"/api/v1/release-orchestrator/deployments/dep-002/targets/tgt-005/logs",
"/api/v1/release-orchestrator/deployments/dep-002/events",
"/api/v1/release-orchestrator/deployments/dep-002/metrics",
};
foreach (var route in routes)
{
var response = await client.GetAsync(route, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/v1/release-orchestrator/deployments/dep-002/pause", null, TestContext.Current.CancellationToken)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/v1/release-orchestrator/deployments/dep-002/cancel", null, TestContext.Current.CancellationToken)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync("/api/v1/release-orchestrator/deployments/dep-001/rollback", null, TestContext.Current.CancellationToken)).StatusCode);
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 EvidenceEndpoints_VerifyHashesAndExportDeterministicBundle()
{
await using var app = await CreateTestAppAsync();
using var client = app.GetTestClient();
var verifyResponse = await client.PostAsync(
"/api/v1/release-orchestrator/evidence/evi-001/verify",
content: null,
TestContext.Current.CancellationToken);
verifyResponse.EnsureSuccessStatusCode();
using var verifyDocument = JsonDocument.Parse(await verifyResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.True(verifyDocument.RootElement.GetProperty("verified").GetBoolean());
Assert.Equal(
verifyDocument.RootElement.GetProperty("hash").GetString(),
verifyDocument.RootElement.GetProperty("computedHash").GetString());
var exportResponse = await client.GetAsync(
"/api/v1/release-orchestrator/evidence/evi-001/export",
TestContext.Current.CancellationToken);
exportResponse.EnsureSuccessStatusCode();
Assert.StartsWith("application/json", exportResponse.Content.Headers.ContentType?.MediaType, StringComparison.Ordinal);
using var exportDocument = JsonDocument.Parse(await exportResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(string.IsNullOrWhiteSpace(exportDocument.RootElement.GetProperty("contentBase64").GetString()));
Assert.True(exportDocument.RootElement.GetProperty("verification").GetProperty("verified").GetBoolean());
var rawResponse = await client.GetAsync(
"/api/v1/release-orchestrator/evidence/evi-001/raw",
TestContext.Current.CancellationToken);
rawResponse.EnsureSuccessStatusCode();
Assert.Equal("application/octet-stream", rawResponse.Content.Headers.ContentType?.MediaType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DashboardPromotionEndpoints_UpdatePendingApprovalState()
{
await using var app = await CreateTestAppAsync();
using var client = app.GetTestClient();
var beforeResponse = await client.GetAsync("/api/v1/release-orchestrator/dashboard", TestContext.Current.CancellationToken);
beforeResponse.EnsureSuccessStatusCode();
using var beforeDocument = JsonDocument.Parse(await beforeResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var pendingBefore = beforeDocument.RootElement.GetProperty("pendingApprovals").GetInt32();
var approveResponse = await client.PostAsync(
"/api/v1/release-orchestrator/promotions/apr-006/approve",
content: null,
TestContext.Current.CancellationToken);
approveResponse.EnsureSuccessStatusCode();
var afterApproveResponse = await client.GetAsync("/api/v1/release-orchestrator/dashboard", TestContext.Current.CancellationToken);
afterApproveResponse.EnsureSuccessStatusCode();
using var afterApproveDocument = JsonDocument.Parse(await afterApproveResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var pendingAfterApprove = afterApproveDocument.RootElement.GetProperty("pendingApprovals").GetInt32();
Assert.Equal(pendingBefore - 1, pendingAfterApprove);
var rejectResponse = await client.PostAsJsonAsync(
"/api/v1/release-orchestrator/promotions/apr-002/reject",
new ReleaseDashboardEndpoints.RejectPromotionRequest("policy gate failed"),
TestContext.Current.CancellationToken);
rejectResponse.EnsureSuccessStatusCode();
var afterRejectResponse = await client.GetAsync("/api/v1/release-orchestrator/dashboard", TestContext.Current.CancellationToken);
afterRejectResponse.EnsureSuccessStatusCode();
using var afterRejectDocument = JsonDocument.Parse(await afterRejectResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var pendingIds = afterRejectDocument.RootElement
.GetProperty("pendingApprovalDetails")
.EnumerateArray()
.Select(item => item.GetProperty("id").GetString())
.ToArray();
Assert.DoesNotContain("apr-006", pendingIds);
Assert.DoesNotContain("apr-002", pendingIds);
}
private static async Task<WebApplication> CreateTestAppAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddSingleton<ReleasePromotionDecisionStore>();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = PassThroughAuthHandler.SchemeName;
options.DefaultChallengeScheme = PassThroughAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, PassThroughAuthHandler>(
PassThroughAuthHandler.SchemeName, static _ => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(JobEnginePolicies.ReleaseRead, policy => policy.RequireAssertion(static _ => true));
options.AddPolicy(JobEnginePolicies.ReleaseWrite, policy => policy.RequireAssertion(static _ => true));
options.AddPolicy(JobEnginePolicies.ReleaseApprove, policy => policy.RequireAssertion(static _ => true));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.MapApprovalEndpoints();
app.MapDeploymentEndpoints();
app.MapEvidenceEndpoints();
app.MapReleaseDashboardEndpoints();
await app.StartAsync();
return app;
}
private sealed class PassThroughAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "ReleaseCompatibilityTests";
public PassThroughAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "compatibility-tests"),
new Claim("stellaops:tenant", "test-tenant"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260323_001-TASK-002-005-T | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `ReleaseCompatibilityEndpointsTests` covering deployment lifecycle routes, deterministic evidence verification/export, and dashboard promotion state changes. |
| S311-SCHEMA-REGRESSION | DONE | Sprint `docs/implplan/SPRINT_20260305_311_JobEngine_consolidation_gap_remediation.md`: added regression tests for runtime/design-time/compiled-model schema consistency (`orchestrator`) and captured targeted class-level evidence. |
| AUDIT-0424-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.JobEngine.Tests. |
| AUDIT-0424-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.JobEngine.Tests. |

View File

@@ -1,4 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.JobEngine.WebService.Endpoints;
@@ -142,14 +145,21 @@ public static class EvidenceEndpoints
var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id);
if (packet is null) return Results.NotFound();
var content = BuildRawContent(packet);
var computedHash = ComputeHash(content, packet.Algorithm);
var verified = string.Equals(packet.Hash, computedHash, StringComparison.OrdinalIgnoreCase);
return Results.Ok(new
{
evidenceId = packet.Id,
verified = true,
verified,
hash = packet.Hash,
computedHash,
algorithm = packet.Algorithm,
verifiedAt = DateTimeOffset.UtcNow,
message = "Evidence integrity verified successfully.",
verifiedAt = packet.VerifiedAt ?? packet.CreatedAt,
message = verified
? "Evidence integrity verified successfully."
: "Evidence integrity verification failed.",
});
}
@@ -158,16 +168,22 @@ public static class EvidenceEndpoints
var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id);
if (packet is null) return Results.NotFound();
var content = BuildRawContent(packet);
var computedHash = ComputeHash(content, packet.Algorithm);
var exportedAt = packet.VerifiedAt ?? packet.CreatedAt;
var bundle = new
{
exportVersion = "1.0",
exportedAt = DateTimeOffset.UtcNow,
exportedAt,
evidence = packet,
contentBase64 = Convert.ToBase64String(content),
verification = new
{
hash = packet.Hash,
computedHash,
algorithm = packet.Algorithm,
verified = true,
verified = string.Equals(packet.Hash, computedHash, StringComparison.OrdinalIgnoreCase),
},
};
@@ -179,9 +195,7 @@ public static class EvidenceEndpoints
var packet = SeedData.EvidencePackets.FirstOrDefault(e => e.Id == id);
if (packet is null) return Results.NotFound();
// Return mock raw bytes representing the evidence content
var content = System.Text.Encoding.UTF8.GetBytes(
$"{{\"evidenceId\":\"{packet.Id}\",\"type\":\"{packet.Type}\",\"raw\":true}}");
var content = BuildRawContent(packet);
return Results.Bytes(content, contentType: "application/octet-stream",
fileDownloadName: $"{packet.Id}.bin");
@@ -200,6 +214,30 @@ public static class EvidenceEndpoints
return Results.Ok(new { evidenceId = id, events = Array.Empty<object>() });
}
private static byte[] BuildRawContent(EvidencePacketDto packet)
{
return JsonSerializer.SerializeToUtf8Bytes(new
{
evidenceId = packet.Id,
releaseId = packet.ReleaseId,
type = packet.Type,
description = packet.Description,
status = packet.Status,
createdBy = packet.CreatedBy,
createdAt = packet.CreatedAt,
});
}
private static string ComputeHash(byte[] content, string algorithm)
{
var normalized = algorithm.Trim().ToUpperInvariant();
return normalized switch
{
"SHA-256" => $"sha256:{Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant()}",
_ => throw new NotSupportedException($"Unsupported evidence hash algorithm '{algorithm}'."),
};
}
// ---- DTOs ----
public sealed record EvidencePacketDto
@@ -233,75 +271,11 @@ public static class EvidenceEndpoints
{
public static readonly List<EvidencePacketDto> EvidencePackets = new()
{
new()
{
Id = "evi-001",
ReleaseId = "rel-001",
Type = "sbom",
Description = "Software Bill of Materials for Platform Release v1.2.3",
Hash = "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
Algorithm = "SHA-256",
SizeBytes = 24576,
Status = "verified",
CreatedBy = "ci-pipeline",
CreatedAt = DateTimeOffset.Parse("2026-01-10T08:15:00Z"),
VerifiedAt = DateTimeOffset.Parse("2026-01-10T08:16:00Z"),
},
new()
{
Id = "evi-002",
ReleaseId = "rel-001",
Type = "attestation",
Description = "Build provenance attestation for Platform Release v1.2.3",
Hash = "sha256:b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
Algorithm = "SHA-256",
SizeBytes = 8192,
Status = "verified",
CreatedBy = "attestor-service",
CreatedAt = DateTimeOffset.Parse("2026-01-10T08:20:00Z"),
VerifiedAt = DateTimeOffset.Parse("2026-01-10T08:21:00Z"),
},
new()
{
Id = "evi-003",
ReleaseId = "rel-002",
Type = "scan-result",
Description = "Security scan results for Platform Release v1.3.0-rc1",
Hash = "sha256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
Algorithm = "SHA-256",
SizeBytes = 16384,
Status = "verified",
CreatedBy = "scanner-service",
CreatedAt = DateTimeOffset.Parse("2026-01-11T10:30:00Z"),
VerifiedAt = DateTimeOffset.Parse("2026-01-11T10:31:00Z"),
},
new()
{
Id = "evi-004",
ReleaseId = "rel-003",
Type = "policy-decision",
Description = "Policy gate evaluation for Hotfix v1.2.4",
Hash = "sha256:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
Algorithm = "SHA-256",
SizeBytes = 4096,
Status = "pending",
CreatedBy = "policy-engine",
CreatedAt = DateTimeOffset.Parse("2026-01-12T06:15:00Z"),
},
new()
{
Id = "evi-005",
ReleaseId = "rel-001",
Type = "deployment-log",
Description = "Production deployment log for Platform Release v1.2.3",
Hash = "sha256:e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
Algorithm = "SHA-256",
SizeBytes = 32768,
Status = "verified",
CreatedBy = "deploy-bot",
CreatedAt = DateTimeOffset.Parse("2026-01-11T14:35:00Z"),
VerifiedAt = DateTimeOffset.Parse("2026-01-11T14:36:00Z"),
},
CreatePacket("evi-001", "rel-001", "sbom", "Software Bill of Materials for Platform Release v1.2.3", 24576, "verified", "ci-pipeline", "2026-01-10T08:15:00Z", "2026-01-10T08:16:00Z"),
CreatePacket("evi-002", "rel-001", "attestation", "Build provenance attestation for Platform Release v1.2.3", 8192, "verified", "attestor-service", "2026-01-10T08:20:00Z", "2026-01-10T08:21:00Z"),
CreatePacket("evi-003", "rel-002", "scan-result", "Security scan results for Platform Release v1.3.0-rc1", 16384, "verified", "scanner-service", "2026-01-11T10:30:00Z", "2026-01-11T10:31:00Z"),
CreatePacket("evi-004", "rel-003", "policy-decision", "Policy gate evaluation for Hotfix v1.2.4", 4096, "pending", "policy-engine", "2026-01-12T06:15:00Z", null),
CreatePacket("evi-005", "rel-001", "deployment-log", "Production deployment log for Platform Release v1.2.3", 32768, "verified", "deploy-bot", "2026-01-11T14:35:00Z", "2026-01-11T14:36:00Z"),
};
public static readonly Dictionary<string, List<EvidenceTimelineEventDto>> Timelines = new()
@@ -319,5 +293,37 @@ public static class EvidenceEndpoints
new() { Id = "evt-e006", EvidenceId = "evi-002", EventType = "verified", Actor = "attestor-service", Message = "Attestation signature verified", Timestamp = DateTimeOffset.Parse("2026-01-10T08:21:00Z") },
},
};
private static EvidencePacketDto CreatePacket(
string id,
string releaseId,
string type,
string description,
long sizeBytes,
string status,
string createdBy,
string createdAt,
string? verifiedAt)
{
var packet = new EvidencePacketDto
{
Id = id,
ReleaseId = releaseId,
Type = type,
Description = description,
Algorithm = "SHA-256",
SizeBytes = sizeBytes,
Status = status,
CreatedBy = createdBy,
CreatedAt = DateTimeOffset.Parse(createdAt),
VerifiedAt = verifiedAt is null ? null : DateTimeOffset.Parse(verifiedAt),
Hash = string.Empty,
};
return packet with
{
Hash = ComputeHash(BuildRawContent(packet), packet.Algorithm),
};
}
}
}

View File

@@ -47,12 +47,12 @@ public static class ReleaseDashboardEndpoints
}
}
private static IResult GetDashboard()
private static IResult GetDashboard(ReleasePromotionDecisionStore decisionStore)
{
var snapshot = ReleaseDashboardSnapshotBuilder.Build();
var approvals = decisionStore.Apply(ApprovalEndpoints.SeedData.Approvals);
var snapshot = ReleaseDashboardSnapshotBuilder.Build(approvals: approvals);
var releases = ReleaseEndpoints.SeedData.Releases;
var approvals = ApprovalEndpoints.SeedData.Approvals;
var byStatus = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
@@ -98,26 +98,82 @@ public static class ReleaseDashboardEndpoints
});
}
private static IResult ApprovePromotion(string id)
private static IResult ApprovePromotion(
string id,
HttpContext context,
ReleasePromotionDecisionStore decisionStore)
{
var approval = ApprovalEndpoints.SeedData.Approvals
.FirstOrDefault(a => string.Equals(a.Id, id, StringComparison.OrdinalIgnoreCase));
if (!decisionStore.TryApprove(
id,
ResolveActor(context),
comment: null,
out var approval,
out var error))
{
if (string.Equals(error, "promotion_not_found", StringComparison.Ordinal))
{
return Results.NotFound(new { message = $"Promotion '{id}' was not found." });
}
return Results.Conflict(new { message = $"Promotion '{id}' is not pending." });
}
if (approval is null)
{
return Results.NotFound(new { message = $"Promotion '{id}' was not found." });
}
return Results.Ok(new { success = true, promotionId = id, action = "approved" });
return Results.Ok(new
{
success = true,
promotionId = id,
action = "approved",
status = approval.Status,
currentApprovals = approval.CurrentApprovals,
});
}
private static IResult RejectPromotion(string id, [FromBody] RejectPromotionRequest? request)
private static IResult RejectPromotion(
string id,
HttpContext context,
ReleasePromotionDecisionStore decisionStore,
[FromBody] RejectPromotionRequest? request)
{
var approval = ApprovalEndpoints.SeedData.Approvals
.FirstOrDefault(a => string.Equals(a.Id, id, StringComparison.OrdinalIgnoreCase));
if (!decisionStore.TryReject(
id,
ResolveActor(context),
request?.Reason,
out var approval,
out var error))
{
if (string.Equals(error, "promotion_not_found", StringComparison.Ordinal))
{
return Results.NotFound(new { message = $"Promotion '{id}' was not found." });
}
return Results.Conflict(new { message = $"Promotion '{id}' is not pending." });
}
if (approval is null)
{
return Results.NotFound(new { message = $"Promotion '{id}' was not found." });
}
return Results.Ok(new { success = true, promotionId = id, action = "rejected", reason = request?.Reason });
return Results.Ok(new
{
success = true,
promotionId = id,
action = "rejected",
status = approval.Status,
reason = request?.Reason,
});
}
private static string ResolveActor(HttpContext context)
{
return context.Request.Headers["X-StellaOps-Actor"].FirstOrDefault()
?? context.User.Identity?.Name
?? "system";
}
public sealed record RejectPromotionRequest(string? Reason);

View File

@@ -113,6 +113,7 @@ builder.Services.AddJobEngineInfrastructure(builder.Configuration);
// Register WebService services
builder.Services.AddSingleton<TenantResolver>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ReleasePromotionDecisionStore>();
// Register streaming options and coordinators
builder.Services.Configure<StreamOptions>(builder.Configuration.GetSection(StreamOptions.SectionName));

View File

@@ -27,19 +27,21 @@ public static class ReleaseDashboardSnapshotBuilder
"rolled_back",
};
public static ReleaseDashboardSnapshot Build()
public static ReleaseDashboardSnapshot Build(
IReadOnlyList<ApprovalEndpoints.ApprovalDto>? approvals = null,
IReadOnlyList<ReleaseEndpoints.ManagedReleaseDto>? releases = null)
{
var releases = ReleaseEndpoints.SeedData.Releases
var releaseItems = (releases ?? ReleaseEndpoints.SeedData.Releases)
.OrderByDescending(release => release.CreatedAt)
.ThenBy(release => release.Id, StringComparer.Ordinal)
.ToArray();
var approvals = ApprovalEndpoints.SeedData.Approvals
var approvalItems = (approvals ?? ApprovalEndpoints.SeedData.Approvals)
.OrderBy(approval => ParseTimestamp(approval.RequestedAt))
.ThenBy(approval => approval.Id, StringComparer.Ordinal)
.ToArray();
var pendingApprovals = approvals
var pendingApprovals = approvalItems
.Where(approval => string.Equals(approval.Status, "pending", StringComparison.OrdinalIgnoreCase))
.Select(approval => new PendingApprovalItem(
approval.Id,
@@ -53,7 +55,7 @@ public static class ReleaseDashboardSnapshotBuilder
NormalizeUrgency(approval.Urgency)))
.ToArray();
var activeDeployments = releases
var activeDeployments = releaseItems
.Where(release => string.Equals(release.Status, "deploying", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(release => release.UpdatedAt)
.ThenBy(release => release.Id, StringComparer.Ordinal)
@@ -83,7 +85,7 @@ public static class ReleaseDashboardSnapshotBuilder
var pipelineEnvironments = PipelineDefinitions
.Select(definition =>
{
var releaseCount = releases.Count(release =>
var releaseCount = releaseItems.Count(release =>
string.Equals(NormalizeEnvironment(release.CurrentEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase));
var pendingCount = pendingApprovals.Count(approval =>
string.Equals(NormalizeEnvironment(approval.TargetEnvironment), definition.NormalizedName, StringComparison.OrdinalIgnoreCase));
@@ -114,7 +116,7 @@ public static class ReleaseDashboardSnapshotBuilder
definition.Id))
.ToArray();
var recentReleases = releases
var recentReleases = releaseItems
.Take(10)
.Select(release => new RecentReleaseItem(
release.Id,

View File

@@ -0,0 +1,214 @@
using System.Collections.Concurrent;
using System.Globalization;
using StellaOps.JobEngine.WebService.Endpoints;
namespace StellaOps.JobEngine.WebService.Services;
/// <summary>
/// Tracks in-memory promotion decisions for the dashboard compatibility endpoints
/// without mutating the shared seed catalog used by deterministic tests.
/// </summary>
public sealed class ReleasePromotionDecisionStore
{
private readonly ConcurrentDictionary<string, ApprovalEndpoints.ApprovalDto> overrides =
new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ApprovalEndpoints.ApprovalDto> Apply(IEnumerable<ApprovalEndpoints.ApprovalDto> approvals)
{
return approvals
.Select(Apply)
.ToArray();
}
public ApprovalEndpoints.ApprovalDto Apply(ApprovalEndpoints.ApprovalDto approval)
{
return overrides.TryGetValue(approval.Id, out var updated)
? updated
: approval;
}
public bool TryApprove(
string approvalId,
string actor,
string? comment,
out ApprovalEndpoints.ApprovalDto? approval,
out string? error)
{
lock (overrides)
{
var current = ResolveCurrentApproval(approvalId);
if (current is null)
{
approval = null;
error = "promotion_not_found";
return false;
}
if (string.Equals(current.Status, "rejected", StringComparison.OrdinalIgnoreCase))
{
approval = null;
error = "promotion_not_pending";
return false;
}
if (string.Equals(current.Status, "approved", StringComparison.OrdinalIgnoreCase))
{
approval = current;
error = null;
return true;
}
var approvedAt = NextTimestamp(current);
var currentApprovals = Math.Min(current.RequiredApprovals, current.CurrentApprovals + 1);
var status = currentApprovals >= current.RequiredApprovals ? "approved" : current.Status;
approval = current with
{
CurrentApprovals = currentApprovals,
Status = status,
Actions = AppendAction(current.Actions, new ApprovalEndpoints.ApprovalActionRecordDto
{
Id = BuildActionId(current.Id, current.Actions.Count + 1),
ApprovalId = current.Id,
Action = "approved",
Actor = actor,
Comment = comment ?? string.Empty,
Timestamp = approvedAt,
}),
Approvers = ApplyApprovalToApprovers(current.Approvers, actor, approvedAt),
};
overrides[approval.Id] = approval;
error = null;
return true;
}
}
public bool TryReject(
string approvalId,
string actor,
string? comment,
out ApprovalEndpoints.ApprovalDto? approval,
out string? error)
{
lock (overrides)
{
var current = ResolveCurrentApproval(approvalId);
if (current is null)
{
approval = null;
error = "promotion_not_found";
return false;
}
if (string.Equals(current.Status, "approved", StringComparison.OrdinalIgnoreCase))
{
approval = null;
error = "promotion_not_pending";
return false;
}
if (string.Equals(current.Status, "rejected", StringComparison.OrdinalIgnoreCase))
{
approval = current;
error = null;
return true;
}
var rejectedAt = NextTimestamp(current);
approval = current with
{
Status = "rejected",
Actions = AppendAction(current.Actions, new ApprovalEndpoints.ApprovalActionRecordDto
{
Id = BuildActionId(current.Id, current.Actions.Count + 1),
ApprovalId = current.Id,
Action = "rejected",
Actor = actor,
Comment = comment ?? string.Empty,
Timestamp = rejectedAt,
}),
};
overrides[approval.Id] = approval;
error = null;
return true;
}
}
private ApprovalEndpoints.ApprovalDto? ResolveCurrentApproval(string approvalId)
{
if (overrides.TryGetValue(approvalId, out var updated))
{
return updated;
}
return ApprovalEndpoints.SeedData.Approvals
.FirstOrDefault(item => string.Equals(item.Id, approvalId, StringComparison.OrdinalIgnoreCase));
}
private static List<ApprovalEndpoints.ApproverDto> ApplyApprovalToApprovers(
List<ApprovalEndpoints.ApproverDto> approvers,
string actor,
string approvedAt)
{
var updated = approvers
.Select(item =>
{
var matchesActor =
string.Equals(item.Id, actor, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.Email, actor, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.Name, actor, StringComparison.OrdinalIgnoreCase);
return matchesActor
? item with { HasApproved = true, ApprovedAt = approvedAt }
: item;
})
.ToList();
if (updated.Any(item => item.HasApproved && string.Equals(item.ApprovedAt, approvedAt, StringComparison.Ordinal)))
{
return updated;
}
updated.Add(new ApprovalEndpoints.ApproverDto
{
Id = actor,
Name = actor,
Email = actor.Contains('@', StringComparison.Ordinal) ? actor : $"{actor}@local",
HasApproved = true,
ApprovedAt = approvedAt,
});
return updated;
}
private static List<ApprovalEndpoints.ApprovalActionRecordDto> AppendAction(
List<ApprovalEndpoints.ApprovalActionRecordDto> actions,
ApprovalEndpoints.ApprovalActionRecordDto action)
{
var updated = actions.ToList();
updated.Add(action);
return updated;
}
private static string BuildActionId(string approvalId, int index)
=> $"{approvalId}-action-{index:D2}";
private static string NextTimestamp(ApprovalEndpoints.ApprovalDto approval)
{
var latestTimestamp = approval.Actions
.Select(action => ParseTimestamp(action.Timestamp))
.Append(ParseTimestamp(approval.RequestedAt))
.Max();
return latestTimestamp.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture);
}
private static DateTimeOffset ParseTimestamp(string value)
{
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: DateTimeOffset.UnixEpoch;
}
}

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| 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. |
| U-002-ORCH-DEADLETTER | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: add/fix deadletter API behavior used by console actions (including export route) and validate local setup usability paths. |
| AUDIT-0425-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.JobEngine.WebService. |
| AUDIT-0425-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.JobEngine.WebService. |