Complete release compatibility and host inventory sprints
Signed-off-by: master <>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user