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. |
|
||||
|
||||
@@ -50,7 +50,10 @@ public sealed record TopologyHostProjection(
|
||||
string Status,
|
||||
string AgentId,
|
||||
int TargetCount,
|
||||
DateTimeOffset? LastSeenAt);
|
||||
DateTimeOffset? LastSeenAt,
|
||||
string? ProbeStatus = null,
|
||||
string? ProbeType = null,
|
||||
DateTimeOffset? ProbeLastHeartbeat = null);
|
||||
|
||||
public sealed record TopologyAgentProjection(
|
||||
string AgentId,
|
||||
|
||||
@@ -24,12 +24,16 @@ public static class AssistantEndpoints
|
||||
|
||||
group.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string route,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? contexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var contextList = string.IsNullOrWhiteSpace(contexts)
|
||||
@@ -46,11 +50,15 @@ public static class AssistantEndpoints
|
||||
|
||||
group.MapGet("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? terms,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var termList = string.IsNullOrWhiteSpace(terms)
|
||||
@@ -67,9 +75,13 @@ public static class AssistantEndpoints
|
||||
|
||||
group.MapGet("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
var state = await store.GetUserStateAsync(userId, tenantId, ct);
|
||||
return state is not null ? Results.Ok(state) : Results.Ok(new AssistantUserStateDto(
|
||||
@@ -80,10 +92,14 @@ public static class AssistantEndpoints
|
||||
|
||||
group.MapPut("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
AssistantUserStateDto state,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
await store.UpsertUserStateAsync(userId, tenantId, state, ct);
|
||||
return Results.Ok();
|
||||
@@ -96,11 +112,15 @@ public static class AssistantEndpoints
|
||||
|
||||
group.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? tourKey,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var result = await store.GetToursAsync(effectiveLocale, tenantId, tourKey, ct);
|
||||
@@ -117,10 +137,14 @@ public static class AssistantEndpoints
|
||||
|
||||
admin.MapPost("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertAssistantTipRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var actor = ResolveUserId(httpContext);
|
||||
var id = await store.UpsertTipAsync(tenantId, request, actor, ct);
|
||||
@@ -130,10 +154,15 @@ public static class AssistantEndpoints
|
||||
.WithSummary("Create or update a tip");
|
||||
|
||||
admin.MapDelete("/tips/{tipId}", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
string tipId,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
await store.DeactivateTipAsync(tipId, ct);
|
||||
return Results.Ok();
|
||||
})
|
||||
@@ -142,11 +171,15 @@ public static class AssistantEndpoints
|
||||
|
||||
admin.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? route,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllTipsAsync(tenantId, locale ?? "en-US", route, ct);
|
||||
return Results.Ok(result);
|
||||
@@ -156,10 +189,14 @@ public static class AssistantEndpoints
|
||||
|
||||
admin.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllToursAsync(tenantId, locale ?? "en-US", ct);
|
||||
return Results.Ok(result);
|
||||
@@ -169,10 +206,14 @@ public static class AssistantEndpoints
|
||||
|
||||
admin.MapPost("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertTourRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertTourAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { tourId = id });
|
||||
@@ -183,10 +224,14 @@ public static class AssistantEndpoints
|
||||
admin.MapGet("/tours/{tourKey}", async Task<IResult>(
|
||||
string tourKey,
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var tour = await store.GetTourByKeyAsync(tenantId, tourKey, locale ?? "en-US", ct);
|
||||
return tour is not null ? Results.Ok(tour) : Results.NotFound();
|
||||
@@ -196,10 +241,14 @@ public static class AssistantEndpoints
|
||||
|
||||
admin.MapPost("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertGlossaryTermRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveStore(httpContext, out var store))
|
||||
{
|
||||
return AssistantStoreUnavailable();
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertGlossaryTermAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { termId = id });
|
||||
@@ -215,6 +264,20 @@ public static class AssistantEndpoints
|
||||
?? ctx.User.FindFirst("stellaops:user_id")?.Value
|
||||
?? "anonymous";
|
||||
|
||||
private static bool TryResolveStore(HttpContext context, out PostgresAssistantStore store)
|
||||
{
|
||||
store = context.RequestServices.GetService<PostgresAssistantStore>()!;
|
||||
return store is not null;
|
||||
}
|
||||
|
||||
private static IResult AssistantStoreUnavailable()
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Assistant persistence is unavailable because the Platform service is running without PostgreSQL.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "assistant_store_unavailable");
|
||||
}
|
||||
|
||||
private static string ResolveTenantId(HttpContext ctx)
|
||||
=> ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.FreezeWindow;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class ReleaseOrchestratorEnvironmentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseOrchestratorEnvironmentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var environments = app.MapGroup("/api/v1/release-orchestrator/environments")
|
||||
.WithTags("Release Orchestrator Environments")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead)
|
||||
.RequireTenant();
|
||||
|
||||
environments.MapGet(string.Empty, ListEnvironments)
|
||||
.WithName("ListReleaseOrchestratorEnvironments")
|
||||
.WithSummary("List release orchestrator environments");
|
||||
|
||||
environments.MapGet("/{id:guid}", GetEnvironment)
|
||||
.WithName("GetReleaseOrchestratorEnvironment")
|
||||
.WithSummary("Get a release orchestrator environment");
|
||||
|
||||
environments.MapPost(string.Empty, CreateEnvironment)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("CreateReleaseOrchestratorEnvironment")
|
||||
.WithSummary("Create a release orchestrator environment");
|
||||
|
||||
environments.MapPut("/{id:guid}", UpdateEnvironment)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("UpdateReleaseOrchestratorEnvironment")
|
||||
.WithSummary("Update a release orchestrator environment");
|
||||
|
||||
environments.MapDelete("/{id:guid}", DeleteEnvironment)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("DeleteReleaseOrchestratorEnvironment")
|
||||
.WithSummary("Delete a release orchestrator environment");
|
||||
|
||||
environments.MapPut("/{id:guid}/settings", UpdateEnvironmentSettings)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("UpdateReleaseOrchestratorEnvironmentSettings")
|
||||
.WithSummary("Update environment release settings");
|
||||
|
||||
environments.MapGet("/{id:guid}/targets", ListTargets)
|
||||
.WithName("ListReleaseOrchestratorEnvironmentTargets")
|
||||
.WithSummary("List environment targets");
|
||||
|
||||
environments.MapPost("/{id:guid}/targets", CreateTarget)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("CreateReleaseOrchestratorEnvironmentTarget")
|
||||
.WithSummary("Create an environment target");
|
||||
|
||||
environments.MapPut("/{id:guid}/targets/{targetId:guid}", UpdateTarget)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("UpdateReleaseOrchestratorEnvironmentTarget")
|
||||
.WithSummary("Update an environment target");
|
||||
|
||||
environments.MapDelete("/{id:guid}/targets/{targetId:guid}", DeleteTarget)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("DeleteReleaseOrchestratorEnvironmentTarget")
|
||||
.WithSummary("Delete an environment target");
|
||||
|
||||
environments.MapPost("/{id:guid}/targets/{targetId:guid}/health-check", CheckTargetHealth)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("HealthCheckReleaseOrchestratorEnvironmentTarget")
|
||||
.WithSummary("Run a target health check");
|
||||
|
||||
environments.MapGet("/{id:guid}/freeze-windows", ListFreezeWindows)
|
||||
.WithName("ListReleaseOrchestratorFreezeWindows")
|
||||
.WithSummary("List environment freeze windows");
|
||||
|
||||
environments.MapPost("/{id:guid}/freeze-windows", CreateFreezeWindow)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("CreateReleaseOrchestratorFreezeWindow")
|
||||
.WithSummary("Create an environment freeze window");
|
||||
|
||||
environments.MapPut("/{id:guid}/freeze-windows/{freezeWindowId:guid}", UpdateFreezeWindow)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("UpdateReleaseOrchestratorFreezeWindow")
|
||||
.WithSummary("Update an environment freeze window");
|
||||
|
||||
environments.MapDelete("/{id:guid}/freeze-windows/{freezeWindowId:guid}", DeleteFreezeWindow)
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
|
||||
.WithName("DeleteReleaseOrchestratorFreezeWindow")
|
||||
.WithSummary("Delete an environment freeze window");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEnvironments(
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await environmentService.ListOrderedAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEnvironment(
|
||||
Guid id,
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var environment = await environmentService.GetAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return environment is not null
|
||||
? Results.Ok(environment)
|
||||
: Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateEnvironment(
|
||||
CreateEnvironmentRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await environmentService.CreateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/release-orchestrator/environments/{created.Id:D}", created);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "environment_validation_failed", details = ex.Errors });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateEnvironment(
|
||||
Guid id,
|
||||
UpdateEnvironmentRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await environmentService.UpdateAsync(id, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (EnvironmentNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "environment_validation_failed", details = ex.Errors });
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<IResult> UpdateEnvironmentSettings(
|
||||
Guid id,
|
||||
UpdateEnvironmentSettingsRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return UpdateEnvironment(
|
||||
id,
|
||||
new UpdateEnvironmentRequest(
|
||||
RequiredApprovals: request.RequiredApprovals,
|
||||
RequireSeparationOfDuties: request.RequireSeparationOfDuties,
|
||||
AutoPromoteFrom: request.AutoPromoteFrom,
|
||||
DeploymentTimeoutSeconds: request.DeploymentTimeoutSeconds),
|
||||
environmentService,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteEnvironment(
|
||||
Guid id,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = await targetRegistry.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (targets.Count > 0)
|
||||
{
|
||||
return Results.Conflict(new { error = "environment_has_targets", id, targetCount = targets.Count });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await environmentService.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (EnvironmentNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = "environment_delete_blocked", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTargets(
|
||||
Guid id,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var targets = await targetRegistry.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(targets);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTarget(
|
||||
Guid id,
|
||||
CreateTargetRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await targetRegistry.RegisterAsync(
|
||||
new RegisterTargetRequest(
|
||||
id,
|
||||
request.Name,
|
||||
request.DisplayName,
|
||||
request.Type,
|
||||
request.ConnectionConfig,
|
||||
request.AgentId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/release-orchestrator/environments/{id:D}/targets/{created.Id:D}",
|
||||
created);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "target_validation_failed", details = ex.Errors });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTarget(
|
||||
Guid id,
|
||||
Guid targetId,
|
||||
UpdateTargetRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
|
||||
if (target is null || target.EnvironmentId != id)
|
||||
{
|
||||
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await targetRegistry.UpdateAsync(targetId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "target_validation_failed", details = ex.Errors });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTarget(
|
||||
Guid id,
|
||||
Guid targetId,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
|
||||
if (target is null || target.EnvironmentId != id)
|
||||
{
|
||||
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await targetRegistry.UnregisterAsync(targetId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = "target_delete_blocked", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CheckTargetHealth(
|
||||
Guid id,
|
||||
Guid targetId,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
|
||||
if (target is null || target.EnvironmentId != id)
|
||||
{
|
||||
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
|
||||
}
|
||||
|
||||
var result = await targetRegistry.TestConnectionAsync(targetId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListFreezeWindows(
|
||||
Guid id,
|
||||
IEnvironmentService environmentService,
|
||||
IFreezeWindowService freezeWindowService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var windows = await freezeWindowService.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(windows);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFreezeWindow(
|
||||
Guid id,
|
||||
CreateFreezeWindowBody request,
|
||||
IEnvironmentService environmentService,
|
||||
IFreezeWindowService freezeWindowService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var created = await freezeWindowService.CreateAsync(
|
||||
new CreateFreezeWindowRequest(
|
||||
id,
|
||||
request.Name,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.Reason,
|
||||
request.IsRecurring,
|
||||
request.RecurrenceRule),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/release-orchestrator/environments/{id:D}/freeze-windows/{created.Id:D}",
|
||||
created);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = "freeze_window_validation_failed", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateFreezeWindow(
|
||||
Guid id,
|
||||
Guid freezeWindowId,
|
||||
UpdateFreezeWindowRequest request,
|
||||
IEnvironmentService environmentService,
|
||||
IFreezeWindowService freezeWindowService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var window = await freezeWindowService.GetAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
|
||||
if (window is null || window.EnvironmentId != id)
|
||||
{
|
||||
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await freezeWindowService.UpdateAsync(freezeWindowId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (FreezeWindowNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteFreezeWindow(
|
||||
Guid id,
|
||||
Guid freezeWindowId,
|
||||
IEnvironmentService environmentService,
|
||||
IFreezeWindowService freezeWindowService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.NotFound(new { error = "environment_not_found", id });
|
||||
}
|
||||
|
||||
var window = await freezeWindowService.GetAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
|
||||
if (window is null || window.EnvironmentId != id)
|
||||
{
|
||||
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
|
||||
}
|
||||
|
||||
await freezeWindowService.DeleteAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<bool> EnvironmentExistsAsync(
|
||||
Guid environmentId,
|
||||
IEnvironmentService environmentService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await environmentService.GetAsync(environmentId, cancellationToken).ConfigureAwait(false) is not null;
|
||||
}
|
||||
|
||||
public sealed record CreateTargetRequest(
|
||||
string Name,
|
||||
string DisplayName,
|
||||
TargetType Type,
|
||||
TargetConnectionConfig ConnectionConfig,
|
||||
Guid? AgentId = null);
|
||||
|
||||
public sealed record UpdateEnvironmentSettingsRequest(
|
||||
int? RequiredApprovals = null,
|
||||
bool? RequireSeparationOfDuties = null,
|
||||
Guid? AutoPromoteFrom = null,
|
||||
int? DeploymentTimeoutSeconds = null);
|
||||
|
||||
public sealed record CreateFreezeWindowBody(
|
||||
string Name,
|
||||
DateTimeOffset StartAt,
|
||||
DateTimeOffset EndAt,
|
||||
string? Reason = null,
|
||||
bool IsRecurring = false,
|
||||
string? RecurrenceRule = null);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ builder.Services.AddOptions<PlatformServiceOptions>()
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<PlatformServiceOptions>, StellaOpsEnvVarPostConfigure>();
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -162,6 +163,7 @@ builder.Services.AddAuthorization(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
builder.Services.AddSingleton<ReleaseOrchestratorCompatibilityIdentityAccessor>();
|
||||
builder.Services.AddSingleton<PlatformCache>();
|
||||
builder.Services.AddSingleton<PlatformAggregationMetrics>();
|
||||
builder.Services.AddSingleton<LegacyAliasTelemetry>();
|
||||
@@ -206,6 +208,44 @@ builder.Services.AddHttpClient("HarborFixture", client =>
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
builder.Services.AddSingleton(sp => new StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore(
|
||||
() => sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId()));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Services.IEnvironmentService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService>(),
|
||||
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId,
|
||||
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetActorId));
|
||||
builder.Services.AddSingleton(sp => new StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore(
|
||||
() => sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId()));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester,
|
||||
StellaOps.ReleaseOrchestrator.Environment.Target.NoOpTargetConnectionTester>();
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetRegistry>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry>(),
|
||||
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.InMemoryFreezeWindowStore>();
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowStore>(sp =>
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.InMemoryFreezeWindowStore>());
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowService>(sp =>
|
||||
new StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.FreezeWindowService(
|
||||
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowStore>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.FreezeWindowService>(),
|
||||
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetActorId));
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Inventory.IRemoteCommandExecutor,
|
||||
StellaOps.ReleaseOrchestrator.Environment.Inventory.NoOpRemoteCommandExecutor>();
|
||||
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Inventory.IInventoryCollector,
|
||||
StellaOps.ReleaseOrchestrator.Environment.Inventory.AgentInventoryCollector>();
|
||||
builder.Services.AddSingleton<TopologyReadModelService>();
|
||||
builder.Services.AddSingleton<StellaOps.ElkSharp.IElkLayoutEngine, StellaOps.ElkSharp.ElkSharpLayeredLayoutEngine>();
|
||||
builder.Services.AddSingleton<TopologyLayoutService>();
|
||||
@@ -350,6 +390,7 @@ app.MapScoreEndpoints();
|
||||
app.MapFunctionMapEndpoints();
|
||||
app.MapPolicyInteropEndpoints();
|
||||
app.MapReleaseControlEndpoints();
|
||||
app.MapReleaseOrchestratorEnvironmentEndpoints();
|
||||
app.MapReleaseReadModelEndpoints();
|
||||
app.MapTopologyReadModelEndpoints();
|
||||
app.MapSecurityReadModelEndpoints();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic GUID identities for compatibility services that
|
||||
/// store tenant and actor keys as GUID values.
|
||||
/// </summary>
|
||||
public sealed class ReleaseOrchestratorCompatibilityIdentityAccessor
|
||||
{
|
||||
private const string DefaultTenant = "_system";
|
||||
private const string DefaultActor = "anonymous";
|
||||
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly PlatformRequestContextResolver requestContextResolver;
|
||||
|
||||
public ReleaseOrchestratorCompatibilityIdentityAccessor(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
PlatformRequestContextResolver requestContextResolver)
|
||||
{
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
this.requestContextResolver = requestContextResolver ?? throw new ArgumentNullException(nameof(requestContextResolver));
|
||||
}
|
||||
|
||||
public Guid GetTenantId()
|
||||
{
|
||||
var key = TryResolveRequestContext(out var context)
|
||||
? context!.TenantId
|
||||
: DefaultTenant;
|
||||
|
||||
return CreateDeterministicGuid($"tenant:{key}");
|
||||
}
|
||||
|
||||
public Guid GetActorId()
|
||||
{
|
||||
var key = TryResolveRequestContext(out var context)
|
||||
? context!.ActorId
|
||||
: DefaultActor;
|
||||
|
||||
return CreateDeterministicGuid($"actor:{key}");
|
||||
}
|
||||
|
||||
private bool TryResolveRequestContext(out PlatformRequestContext? requestContext)
|
||||
{
|
||||
requestContext = null;
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
return httpContext is not null
|
||||
&& requestContextResolver.TryResolve(httpContext, out requestContext, out _);
|
||||
}
|
||||
|
||||
internal static Guid CreateDeterministicGuid(string value)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
hash.AsSpan(0, 16).CopyTo(bytes);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -358,9 +358,10 @@ public sealed class TopologyReadModelService
|
||||
.GroupBy(target => target.HostId, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group
|
||||
var orderedTargets = group
|
||||
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
|
||||
.First();
|
||||
.ToArray();
|
||||
var first = orderedTargets[0];
|
||||
var hostStatus = ResolveHostStatus(group.Select(target => target.HealthStatus));
|
||||
var lastSeen = MaxTimestamp(group.Select(target => target.LastSyncAt));
|
||||
|
||||
@@ -372,7 +373,7 @@ public sealed class TopologyReadModelService
|
||||
RuntimeType: first.TargetType,
|
||||
Status: hostStatus,
|
||||
AgentId: first.AgentId,
|
||||
TargetCount: group.Count(),
|
||||
TargetCount: orderedTargets.Length,
|
||||
LastSeenAt: lastSeen,
|
||||
ProbeStatus: "not_installed",
|
||||
ProbeType: null,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
|
||||
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Environment\StellaOps.ReleaseOrchestrator.Environment.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
|
||||
@@ -16,8 +16,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| B22-04 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/security/{findings,disposition/{findingId},sbom-explorer}` read contracts, `platform.security.read` policy mapping, and migration `050_SecurityDispositionProjection.sql` integration. |
|
||||
| B22-05 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/integrations/{feeds,vex-sources}` contracts with deterministic source type/status/freshness/last-sync metadata and migration `051_IntegrationSourceHealth.sql`. |
|
||||
| B22-06 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v1/*` compatibility aliases for Pack 22 critical surfaces and deterministic deprecation telemetry for alias usage. |
|
||||
| SPRINT_20260323_001-TASK-004 | DONE | Sprint `docs/implplan/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `/api/v1/release-orchestrator/environments/*` compatibility endpoints for environment, target, and freeze-window CRUD using deterministic in-memory Release Orchestrator services. |
|
||||
| SPRINT_20260331_002-TASK-003 | DONE | Sprint `docs/implplan/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: topology host projections now expose projection-derived `ProbeStatus`, `ProbeType`, and `ProbeLastHeartbeat` fields for Console host inventory views. |
|
||||
| SPRINT_20260323_001-TASK-004 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `/api/v1/release-orchestrator/environments/*` compatibility endpoints for environment, target, and freeze-window CRUD using deterministic in-memory Release Orchestrator services. |
|
||||
| SPRINT_20260331_002-TASK-003 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: topology host projections now expose projection-derived `ProbeStatus`, `ProbeType`, and `ProbeLastHeartbeat` fields for Console host inventory views. |
|
||||
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Endpoints;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.FreezeWindow;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Inventory;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using RoEnvironment = StellaOps.ReleaseOrchestrator.Environment.Models.Environment;
|
||||
using RoFreezeWindow = StellaOps.ReleaseOrchestrator.Environment.Models.FreezeWindow;
|
||||
using RoTarget = StellaOps.ReleaseOrchestrator.Environment.Models.Target;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseOrchestratorEnvironmentEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public ReleaseOrchestratorEnvironmentEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EnvironmentLifecycle_CreateTargetFreezeWindowAndDelete_Works()
|
||||
{
|
||||
using var client = CreateTenantClient("tenant-env-lifecycle");
|
||||
|
||||
var createEnvironmentResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-orchestrator/environments",
|
||||
new CreateEnvironmentRequest(
|
||||
Name: "prod-eu",
|
||||
DisplayName: "Production EU",
|
||||
Description: "Primary production environment",
|
||||
OrderIndex: 2,
|
||||
IsProduction: true,
|
||||
RequiredApprovals: 2,
|
||||
RequireSeparationOfDuties: true,
|
||||
AutoPromoteFrom: null,
|
||||
DeploymentTimeoutSeconds: 900),
|
||||
TestContext.Current.CancellationToken);
|
||||
createEnvironmentResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var environment = await createEnvironmentResponse.Content.ReadFromJsonAsync<RoEnvironment>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(environment);
|
||||
|
||||
var list = await client.GetFromJsonAsync<List<RoEnvironment>>(
|
||||
"/api/v1/release-orchestrator/environments",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(list);
|
||||
Assert.Contains(list!, item => item.Id == environment!.Id);
|
||||
|
||||
var updateSettingsResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment!.Id:D}/settings",
|
||||
new ReleaseOrchestratorEnvironmentEndpoints.UpdateEnvironmentSettingsRequest(
|
||||
RequiredApprovals: 3,
|
||||
RequireSeparationOfDuties: true,
|
||||
DeploymentTimeoutSeconds: 1200),
|
||||
TestContext.Current.CancellationToken);
|
||||
updateSettingsResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updatedEnvironment = await updateSettingsResponse.Content.ReadFromJsonAsync<RoEnvironment>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updatedEnvironment);
|
||||
Assert.Equal(3, updatedEnvironment!.RequiredApprovals);
|
||||
Assert.Equal(1200, updatedEnvironment.DeploymentTimeoutSeconds);
|
||||
|
||||
var createTargetResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets",
|
||||
new ReleaseOrchestratorEnvironmentEndpoints.CreateTargetRequest(
|
||||
Name: "prod-eu-ssh-01",
|
||||
DisplayName: "Prod EU SSH 01",
|
||||
Type: TargetType.SshHost,
|
||||
ConnectionConfig: new SshHostConfig
|
||||
{
|
||||
Host = "ssh.prod-eu.internal",
|
||||
Username = "deploy",
|
||||
PrivateKeySecretRef = "secret://ssh/prod-eu",
|
||||
KnownHostsPolicy = KnownHostsPolicy.Prompt,
|
||||
}),
|
||||
TestContext.Current.CancellationToken);
|
||||
createTargetResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var target = await createTargetResponse.Content.ReadFromJsonAsync<RoTarget>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(target);
|
||||
Assert.Equal(TargetType.SshHost, target!.Type);
|
||||
|
||||
var healthCheckResponse = await client.PostAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets/{target.Id:D}/health-check",
|
||||
content: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
healthCheckResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var healthCheck = await healthCheckResponse.Content.ReadFromJsonAsync<ConnectionTestResult>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(healthCheck);
|
||||
Assert.True(healthCheck!.Success);
|
||||
|
||||
var targets = await client.GetFromJsonAsync<List<RoTarget>>(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(targets);
|
||||
Assert.Contains(targets!, item => item.Id == target.Id);
|
||||
|
||||
var createFreezeWindowResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows",
|
||||
new ReleaseOrchestratorEnvironmentEndpoints.CreateFreezeWindowBody(
|
||||
Name: "Weekend Freeze",
|
||||
StartAt: new DateTimeOffset(2026, 4, 4, 0, 0, 0, TimeSpan.Zero),
|
||||
EndAt: new DateTimeOffset(2026, 4, 6, 0, 0, 0, TimeSpan.Zero),
|
||||
Reason: "Weekend release freeze"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createFreezeWindowResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var freezeWindow = await createFreezeWindowResponse.Content.ReadFromJsonAsync<RoFreezeWindow>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(freezeWindow);
|
||||
|
||||
var freezeWindows = await client.GetFromJsonAsync<List<RoFreezeWindow>>(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(freezeWindows);
|
||||
Assert.Contains(freezeWindows!, item => item.Id == freezeWindow!.Id);
|
||||
|
||||
var updateFreezeWindowResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows/{freezeWindow!.Id:D}",
|
||||
new UpdateFreezeWindowRequest(
|
||||
Name: "Weekend Freeze Updated",
|
||||
Reason: "Extended validation window"),
|
||||
TestContext.Current.CancellationToken);
|
||||
updateFreezeWindowResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updatedFreezeWindow = await updateFreezeWindowResponse.Content.ReadFromJsonAsync<RoFreezeWindow>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updatedFreezeWindow);
|
||||
Assert.Equal("Weekend Freeze Updated", updatedFreezeWindow!.Name);
|
||||
|
||||
var deleteFreezeWindowResponse = await client.DeleteAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows/{freezeWindow.Id:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteFreezeWindowResponse.StatusCode);
|
||||
|
||||
var deleteTargetResponse = await client.DeleteAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets/{target.Id:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteTargetResponse.StatusCode);
|
||||
|
||||
var deleteEnvironmentResponse = await client.DeleteAsync(
|
||||
$"/api/v1/release-orchestrator/environments/{environment.Id:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteEnvironmentResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompatibilityServices_RegisterAgentInventoryCollector()
|
||||
{
|
||||
var collector = factory.Services.GetRequiredService<IInventoryCollector>();
|
||||
Assert.IsType<AgentInventoryCollector>(collector);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "platform-compat-test");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| B22-04-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `SecurityReadModelEndpointsTests` + `SecurityDispositionMigrationScriptTests` for `/api/v2/security/{findings,disposition,sbom-explorer}` deterministic behavior, policy metadata, write-boundary checks, and migration `050` coverage. |
|
||||
| B22-05-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `IntegrationsReadModelEndpointsTests` + `IntegrationSourceHealthMigrationScriptTests` for `/api/v2/integrations/{feeds,vex-sources}` deterministic behavior, policy metadata, consumer compatibility, and migration `051` coverage. |
|
||||
| B22-06-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added compatibility+telemetry contract tests covering both `/api/v1/*` aliases and `/api/v2/*` canonical routes for critical Pack 22 surfaces. |
|
||||
| SPRINT_20260323_001-TASK-004-T | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `ReleaseOrchestratorEnvironmentEndpointsTests` for environment, target, freeze-window, and compatibility DI wiring. |
|
||||
| SPRINT_20260331_002-TASK-003-T | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: extended `TopologyReadModelEndpointsTests` to verify host probe status/type/heartbeat projection fields. |
|
||||
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebA
|
||||
Assert.Equal("not_installed", item.ProbeStatus);
|
||||
Assert.Null(item.ProbeType);
|
||||
Assert.Null(item.ProbeLastHeartbeat);
|
||||
});
|
||||
});
|
||||
|
||||
var agentsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
|
||||
"/api/v2/topology/agents?limit=20&offset=0",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260331_002_BE_host_infrastructure_and_inventory
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a remote command execution on a target host.
|
||||
/// </summary>
|
||||
public sealed record RemoteCommandResult(
|
||||
bool Success,
|
||||
string? Output,
|
||||
string? Error,
|
||||
TimeSpan Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for executing commands on remote hosts via their assigned agents.
|
||||
/// Implemented by the Deployment layer which has access to agent dispatch infrastructure.
|
||||
/// </summary>
|
||||
public interface IRemoteCommandExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a shell command on the agent assigned to the target.
|
||||
/// </summary>
|
||||
Task<RemoteCommandResult> ExecuteAsync(
|
||||
Models.Target target,
|
||||
string command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default executor used when no agent dispatch integration is available.
|
||||
/// </summary>
|
||||
public sealed class NoOpRemoteCommandExecutor : IRemoteCommandExecutor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<RemoteCommandResult> ExecuteAsync(
|
||||
Models.Target target,
|
||||
string command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new RemoteCommandResult(
|
||||
Success: false,
|
||||
Output: null,
|
||||
Error: $"No remote command executor is configured for target '{target.Name}'.",
|
||||
Duration: TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects container inventory from deployment targets by dispatching
|
||||
/// <c>docker ps --format json --no-trunc</c> via the target's assigned agent.
|
||||
/// </summary>
|
||||
public sealed class AgentInventoryCollector : IInventoryCollector
|
||||
{
|
||||
private readonly IRemoteCommandExecutor _executor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AgentInventoryCollector> _logger;
|
||||
|
||||
private static readonly TimeSpan CollectionTimeout = TimeSpan.FromSeconds(30);
|
||||
private const string DockerPsCommand = "docker ps --format '{{json .}}' --no-trunc";
|
||||
|
||||
public AgentInventoryCollector(
|
||||
IRemoteCommandExecutor executor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AgentInventoryCollector> logger)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InventorySnapshot> CollectAsync(Models.Target target, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
if (target.AgentId is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Target {TargetName} has no assigned agent — cannot collect inventory",
|
||||
target.Name);
|
||||
return ErrorSnapshot(target.Id, "No agent assigned to target");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _executor.ExecuteAsync(target, DockerPsCommand, CollectionTimeout, ct);
|
||||
|
||||
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
|
||||
{
|
||||
return ErrorSnapshot(target.Id, result.Error ?? "Command returned no data");
|
||||
}
|
||||
|
||||
return ParseDockerPsOutput(target.Id, result.Output);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to collect inventory from target {TargetName} ({TargetType})",
|
||||
target.Name, target.Type);
|
||||
return ErrorSnapshot(target.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses NDJSON output from <c>docker ps --format json</c> into an InventorySnapshot.
|
||||
/// Each line is a JSON object with fields: ID, Names, Image, Status, Ports, Labels, CreatedAt.
|
||||
/// </summary>
|
||||
internal InventorySnapshot ParseDockerPsOutput(Guid targetId, string output)
|
||||
{
|
||||
var containers = new List<ContainerInfo>();
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith('{'))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var id = root.TryGetProperty("ID", out var idProp) ? idProp.GetString() ?? "" : "";
|
||||
var names = root.TryGetProperty("Names", out var namesProp) ? namesProp.GetString() ?? "" : "";
|
||||
var image = root.TryGetProperty("Image", out var imageProp) ? imageProp.GetString() ?? "" : "";
|
||||
var status = root.TryGetProperty("Status", out var statusProp) ? statusProp.GetString() ?? "" : "";
|
||||
var createdAt = root.TryGetProperty("CreatedAt", out var createdProp) ? createdProp.GetString() ?? "" : "";
|
||||
|
||||
var labels = ImmutableDictionary<string, string>.Empty;
|
||||
if (root.TryGetProperty("Labels", out var labelsProp) && labelsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var labelsStr = labelsProp.GetString() ?? "";
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>();
|
||||
foreach (var pair in labelsStr.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
if (kv.Length == 2)
|
||||
builder[kv[0].Trim()] = kv[1].Trim();
|
||||
}
|
||||
labels = builder.ToImmutable();
|
||||
}
|
||||
|
||||
var ports = ImmutableArray<PortMapping>.Empty;
|
||||
if (root.TryGetProperty("Ports", out var portsProp) && portsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var portsStr = portsProp.GetString() ?? "";
|
||||
var portsBuilder = ImmutableArray.CreateBuilder<PortMapping>();
|
||||
foreach (var portSpec in portsStr.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = portSpec.Trim();
|
||||
if (trimmed.Contains("->"))
|
||||
{
|
||||
var parts = trimmed.Split("->");
|
||||
var pubPart = parts[0].Split(':');
|
||||
var privPart = parts[1].Split('/');
|
||||
if (int.TryParse(privPart[0], out var priv))
|
||||
{
|
||||
int? pub = pubPart.Length > 1 && int.TryParse(pubPart[^1], out var p) ? p : null;
|
||||
portsBuilder.Add(new PortMapping(priv, pub, privPart.Length > 1 ? privPart[1] : "tcp"));
|
||||
}
|
||||
}
|
||||
}
|
||||
ports = portsBuilder.ToImmutable();
|
||||
}
|
||||
|
||||
DateTimeOffset? parsedCreated = DateTimeOffset.TryParse(createdAt, out var dt) ? dt : null;
|
||||
|
||||
containers.Add(new ContainerInfo(
|
||||
Id: id,
|
||||
Name: names.TrimStart('/'),
|
||||
Image: image,
|
||||
ImageDigest: "",
|
||||
Status: status,
|
||||
Labels: labels,
|
||||
Ports: ports,
|
||||
CreatedAt: parsedCreated ?? _timeProvider.GetUtcNow(),
|
||||
StartedAt: status.StartsWith("Up", StringComparison.OrdinalIgnoreCase) ? parsedCreated : null));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse docker ps line: {Line}", line);
|
||||
}
|
||||
}
|
||||
|
||||
return new InventorySnapshot
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetId = targetId,
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
Containers = containers.ToImmutableArray(),
|
||||
Networks = [],
|
||||
Volumes = [],
|
||||
};
|
||||
}
|
||||
|
||||
private InventorySnapshot ErrorSnapshot(Guid targetId, string error) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetId = targetId,
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
Containers = [],
|
||||
Networks = [],
|
||||
Volumes = [],
|
||||
CollectionError = error,
|
||||
};
|
||||
}
|
||||
@@ -104,7 +104,17 @@ public enum TargetType
|
||||
/// <summary>
|
||||
/// HashiCorp Nomad job.
|
||||
/// </summary>
|
||||
NomadJob = 3
|
||||
NomadJob = 3,
|
||||
|
||||
/// <summary>
|
||||
/// SSH-managed host (bare-metal or VM).
|
||||
/// </summary>
|
||||
SshHost = 4,
|
||||
|
||||
/// <summary>
|
||||
/// WinRM-managed Windows host.
|
||||
/// </summary>
|
||||
WinRmHost = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
[JsonDerivedType(typeof(ComposeHostConfig), "compose_host")]
|
||||
[JsonDerivedType(typeof(EcsServiceConfig), "ecs_service")]
|
||||
[JsonDerivedType(typeof(NomadJobConfig), "nomad_job")]
|
||||
[JsonDerivedType(typeof(SshHostConfig), "ssh_host")]
|
||||
[JsonDerivedType(typeof(WinRmHostConfig), "winrm_host")]
|
||||
public abstract record TargetConnectionConfig
|
||||
{
|
||||
/// <summary>
|
||||
@@ -178,3 +180,120 @@ public sealed record NomadJobConfig : TargetConnectionConfig
|
||||
/// </summary>
|
||||
public bool UseTls { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for SSH host connection.
|
||||
/// Used for bare-metal and VM targets managed via SSH.
|
||||
/// </summary>
|
||||
public sealed record SshHostConfig : TargetConnectionConfig
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override TargetType TargetType => TargetType.SshHost;
|
||||
|
||||
/// <summary>
|
||||
/// SSH host address (hostname or IP).
|
||||
/// </summary>
|
||||
public required string Host { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SSH port.
|
||||
/// </summary>
|
||||
public int Port { get; init; } = 22;
|
||||
|
||||
/// <summary>
|
||||
/// SSH username.
|
||||
/// </summary>
|
||||
public required string Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret reference for private key.
|
||||
/// </summary>
|
||||
public string? PrivateKeySecretRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret reference for password (fallback if no key).
|
||||
/// </summary>
|
||||
public string? PasswordSecretRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known hosts verification policy.
|
||||
/// </summary>
|
||||
public KnownHostsPolicy KnownHostsPolicy { get; init; } = KnownHostsPolicy.Accept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for WinRM host connection.
|
||||
/// Used for Windows targets managed via WS-Management.
|
||||
/// </summary>
|
||||
public sealed record WinRmHostConfig : TargetConnectionConfig
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override TargetType TargetType => TargetType.WinRmHost;
|
||||
|
||||
/// <summary>
|
||||
/// WinRM host address (hostname or IP).
|
||||
/// </summary>
|
||||
public required string Host { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// WinRM port.
|
||||
/// </summary>
|
||||
public int Port { get; init; } = 5985;
|
||||
|
||||
/// <summary>
|
||||
/// Transport protocol.
|
||||
/// </summary>
|
||||
public WinRmTransport Transport { get; init; } = WinRmTransport.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Windows username.
|
||||
/// </summary>
|
||||
public required string Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret reference for password.
|
||||
/// </summary>
|
||||
public string? PasswordSecretRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Active Directory domain (if domain-joined).
|
||||
/// </summary>
|
||||
public string? Domain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SSH known hosts verification policy.
|
||||
/// </summary>
|
||||
public enum KnownHostsPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Accept all host keys (least secure, simplest setup).
|
||||
/// </summary>
|
||||
Accept = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Strict verification against known_hosts file.
|
||||
/// </summary>
|
||||
Strict = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Prompt before trusting a new host key.
|
||||
/// </summary>
|
||||
Prompt = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WinRM transport protocol.
|
||||
/// </summary>
|
||||
public enum WinRmTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain HTTP (port 5985).
|
||||
/// </summary>
|
||||
Http = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTTPS with TLS (port 5986).
|
||||
/// </summary>
|
||||
Https = 1
|
||||
}
|
||||
|
||||
@@ -89,6 +89,27 @@ public sealed partial class TargetRegistry : ITargetRegistry
|
||||
var existing = await _store.GetAsync(id, ct)
|
||||
?? throw new TargetNotFoundException(id);
|
||||
|
||||
var errors = new List<string>();
|
||||
if (request.DisplayName is not null && string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
errors.Add("Display name is required");
|
||||
}
|
||||
|
||||
if (request.ConnectionConfig is not null)
|
||||
{
|
||||
if (request.ConnectionConfig.TargetType != existing.Type)
|
||||
{
|
||||
errors.Add($"Connection config type {request.ConnectionConfig.TargetType} does not match target type {existing.Type}");
|
||||
}
|
||||
|
||||
ValidateConnectionConfig(request.ConnectionConfig, errors);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ValidationException(errors);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
@@ -332,6 +353,28 @@ public sealed partial class TargetRegistry : ITargetRegistry
|
||||
if (string.IsNullOrWhiteSpace(nomad.JobId))
|
||||
errors.Add("Nomad job ID is required");
|
||||
break;
|
||||
|
||||
case SshHostConfig ssh:
|
||||
if (string.IsNullOrWhiteSpace(ssh.Host))
|
||||
errors.Add("SSH host address is required");
|
||||
if (ssh.Port < 1 || ssh.Port > 65535)
|
||||
errors.Add("SSH port must be between 1 and 65535");
|
||||
if (string.IsNullOrWhiteSpace(ssh.Username))
|
||||
errors.Add("SSH username is required");
|
||||
if (string.IsNullOrWhiteSpace(ssh.PrivateKeySecretRef) && string.IsNullOrWhiteSpace(ssh.PasswordSecretRef))
|
||||
errors.Add("SSH requires either a private key or password secret reference");
|
||||
break;
|
||||
|
||||
case WinRmHostConfig winrm:
|
||||
if (string.IsNullOrWhiteSpace(winrm.Host))
|
||||
errors.Add("WinRM host address is required");
|
||||
if (winrm.Port < 1 || winrm.Port > 65535)
|
||||
errors.Add("WinRM port must be between 1 and 65535");
|
||||
if (string.IsNullOrWhiteSpace(winrm.Username))
|
||||
errors.Add("WinRM username is required");
|
||||
if (string.IsNullOrWhiteSpace(winrm.PasswordSecretRef))
|
||||
errors.Add("WinRM password secret reference is required");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Inventory;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using Xunit;
|
||||
using ModelTarget = StellaOps.ReleaseOrchestrator.Environment.Models.Target;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Inventory;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AgentInventoryCollectorTests
|
||||
{
|
||||
private readonly FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 3, 31, 9, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithoutAssignedAgent_ReturnsErrorSnapshot()
|
||||
{
|
||||
var collector = new AgentInventoryCollector(
|
||||
new FakeRemoteCommandExecutor(new RemoteCommandResult(true, string.Empty, null, TimeSpan.Zero)),
|
||||
timeProvider,
|
||||
Mock.Of<ILogger<AgentInventoryCollector>>());
|
||||
|
||||
var snapshot = await collector.CollectAsync(CreateTarget(agentId: null), TestContext.Current.CancellationToken);
|
||||
|
||||
snapshot.IsSuccessful.Should().BeFalse();
|
||||
snapshot.CollectionError.Should().Contain("No agent assigned");
|
||||
snapshot.TargetId.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_ParsesDockerPsJsonOutput()
|
||||
{
|
||||
var output = string.Join('\n', new[]
|
||||
{
|
||||
"{\"ID\":\"container-001\",\"Names\":\"/api\",\"Image\":\"stella/api:1.2.3\",\"Status\":\"Up 3 hours\",\"Ports\":\"0.0.0.0:8080->80/tcp, 8443->443/tcp\",\"Labels\":\"app=api,env=prod\",\"CreatedAt\":\"2026-03-31T05:00:00Z\"}",
|
||||
"{\"ID\":\"container-002\",\"Names\":\"/worker\",\"Image\":\"stella/worker:1.2.3\",\"Status\":\"Exited (0) 10 minutes ago\",\"Ports\":\"\",\"Labels\":\"app=worker\",\"CreatedAt\":\"2026-03-31T04:30:00Z\"}",
|
||||
});
|
||||
|
||||
var collector = new AgentInventoryCollector(
|
||||
new FakeRemoteCommandExecutor(new RemoteCommandResult(true, output, null, TimeSpan.FromSeconds(2))),
|
||||
timeProvider,
|
||||
Mock.Of<ILogger<AgentInventoryCollector>>());
|
||||
|
||||
var snapshot = await collector.CollectAsync(CreateTarget(agentId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), TestContext.Current.CancellationToken);
|
||||
|
||||
snapshot.IsSuccessful.Should().BeTrue();
|
||||
snapshot.Containers.Should().HaveCount(2);
|
||||
snapshot.Containers[0].Name.Should().Be("api");
|
||||
snapshot.Containers[0].Labels["app"].Should().Be("api");
|
||||
snapshot.Containers[0].Ports.Should().ContainSingle(port => port.PrivatePort == 80 && port.PublicPort == 8080);
|
||||
snapshot.Containers[0].StartedAt.Should().Be(DateTimeOffset.Parse("2026-03-31T05:00:00Z"));
|
||||
snapshot.Containers[1].Name.Should().Be("worker");
|
||||
snapshot.Containers[1].StartedAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_CommandFailure_ReturnsErrorSnapshot()
|
||||
{
|
||||
var collector = new AgentInventoryCollector(
|
||||
new FakeRemoteCommandExecutor(new RemoteCommandResult(false, null, "agent offline", TimeSpan.FromSeconds(1))),
|
||||
timeProvider,
|
||||
Mock.Of<ILogger<AgentInventoryCollector>>());
|
||||
|
||||
var snapshot = await collector.CollectAsync(CreateTarget(agentId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")), TestContext.Current.CancellationToken);
|
||||
|
||||
snapshot.IsSuccessful.Should().BeFalse();
|
||||
snapshot.CollectionError.Should().Be("agent offline");
|
||||
snapshot.CollectedAt.Should().Be(timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private ModelTarget CreateTarget(Guid? agentId)
|
||||
{
|
||||
return new ModelTarget
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
TenantId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
EnvironmentId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
Name = "ssh-prod-01",
|
||||
DisplayName = "SSH Prod 01",
|
||||
Type = TargetType.SshHost,
|
||||
ConnectionConfig = new SshHostConfig
|
||||
{
|
||||
Host = "ssh.example.internal",
|
||||
Username = "deploy",
|
||||
PrivateKeySecretRef = "secret://ssh/deploy",
|
||||
},
|
||||
AgentId = agentId,
|
||||
HealthStatus = HealthStatus.Unknown,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeRemoteCommandExecutor : IRemoteCommandExecutor
|
||||
{
|
||||
private readonly RemoteCommandResult result;
|
||||
|
||||
public FakeRemoteCommandExecutor(RemoteCommandResult result)
|
||||
{
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
public Task<RemoteCommandResult> ExecuteAsync(
|
||||
ModelTarget target,
|
||||
string command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Serialization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TargetConnectionConfigSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SshHostConfig_RoundTripsWithPromptKnownHostsPolicy()
|
||||
{
|
||||
TargetConnectionConfig config = new SshHostConfig
|
||||
{
|
||||
Host = "ssh.example.internal",
|
||||
Username = "deploy",
|
||||
PrivateKeySecretRef = "secret://ssh/deploy",
|
||||
KnownHostsPolicy = KnownHostsPolicy.Prompt,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
var roundTrip = JsonSerializer.Deserialize<TargetConnectionConfig>(json);
|
||||
|
||||
json.Should().Contain("\"type\":\"ssh_host\"");
|
||||
roundTrip.Should().BeOfType<SshHostConfig>();
|
||||
roundTrip.As<SshHostConfig>().Host.Should().Be("ssh.example.internal");
|
||||
roundTrip.As<SshHostConfig>().Username.Should().Be("deploy");
|
||||
roundTrip.As<SshHostConfig>().KnownHostsPolicy.Should().Be(KnownHostsPolicy.Prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WinRmHostConfig_RoundTripsWithHttpsTransport()
|
||||
{
|
||||
TargetConnectionConfig config = new WinRmHostConfig
|
||||
{
|
||||
Host = "winrm.example.internal",
|
||||
Port = 5986,
|
||||
Transport = WinRmTransport.Https,
|
||||
Username = "ops-admin",
|
||||
PasswordSecretRef = "secret://winrm/password",
|
||||
Domain = "CORP",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
var roundTrip = JsonSerializer.Deserialize<TargetConnectionConfig>(json);
|
||||
|
||||
json.Should().Contain("\"type\":\"winrm_host\"");
|
||||
roundTrip.Should().BeOfType<WinRmHostConfig>();
|
||||
roundTrip.As<WinRmHostConfig>().Host.Should().Be("winrm.example.internal");
|
||||
roundTrip.As<WinRmHostConfig>().Transport.Should().Be(WinRmTransport.Https);
|
||||
roundTrip.As<WinRmHostConfig>().Domain.Should().Be("CORP");
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,30 @@ public sealed class TargetRegistryTests
|
||||
result.ConnectionConfig.Should().BeOfType<EcsServiceConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_ValidSshTarget_Succeeds()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new RegisterTargetRequest(
|
||||
EnvironmentId: _environmentId,
|
||||
Name: "ssh-host-1",
|
||||
DisplayName: "SSH Host 1",
|
||||
Type: TargetType.SshHost,
|
||||
ConnectionConfig: new SshHostConfig
|
||||
{
|
||||
Host = "ssh.example.internal",
|
||||
Username = "deploy",
|
||||
PrivateKeySecretRef = "secret://ssh/deploy",
|
||||
KnownHostsPolicy = KnownHostsPolicy.Prompt,
|
||||
});
|
||||
|
||||
var result = await _registry.RegisterAsync(request, ct);
|
||||
|
||||
result.Type.Should().Be(TargetType.SshHost);
|
||||
result.ConnectionConfig.Should().BeOfType<SshHostConfig>();
|
||||
result.ConnectionConfig.As<SshHostConfig>().KnownHostsPolicy.Should().Be(KnownHostsPolicy.Prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_DuplicateName_Fails()
|
||||
{
|
||||
@@ -261,6 +285,39 @@ public sealed class TargetRegistryTests
|
||||
await act.Should().ThrowAsync<TargetNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithInvalidConnectionConfig_Fails()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var created = await _registry.RegisterAsync(
|
||||
new RegisterTargetRequest(
|
||||
_environmentId,
|
||||
"ssh-host-2",
|
||||
"SSH Host 2",
|
||||
TargetType.SshHost,
|
||||
new SshHostConfig
|
||||
{
|
||||
Host = "ssh-2.example.internal",
|
||||
Username = "deploy",
|
||||
PrivateKeySecretRef = "secret://ssh/deploy",
|
||||
}),
|
||||
ct);
|
||||
|
||||
var act = () => _registry.UpdateAsync(
|
||||
created.Id,
|
||||
new UpdateTargetRequest(
|
||||
ConnectionConfig: new WinRmHostConfig
|
||||
{
|
||||
Host = "winrm.example.internal",
|
||||
Username = "ops",
|
||||
PasswordSecretRef = "secret://winrm/password",
|
||||
}),
|
||||
ct);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>()
|
||||
.WithMessage("*does not match target type*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unregister_WithoutActiveDeployments_Succeeds()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -90,7 +92,7 @@ internal static class RegistryEndpoints
|
||||
new RegistryDigestEntry
|
||||
{
|
||||
Tag = "latest",
|
||||
Digest = $"sha256:{Guid.NewGuid():N}",
|
||||
Digest = CreateDeterministicDigest(repository),
|
||||
PushedAt = "2026-03-20T10:00:00Z"
|
||||
}
|
||||
}
|
||||
@@ -256,4 +258,10 @@ internal static class RegistryEndpoints
|
||||
LastPushed = "2026-03-20T15:00:00Z"
|
||||
},
|
||||
};
|
||||
|
||||
private static string CreateDeterministicDigest(string repository)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(repository.Trim().ToLowerInvariant()));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class RegistryEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RegistrySearchAndDigestEndpoints_ReturnExpectedResults()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var searchResponse = await client.GetAsync(
|
||||
"/api/v1/registries/images/search?q=nginx",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, searchResponse.StatusCode);
|
||||
|
||||
var searchPayload = await searchResponse.Content.ReadFromJsonAsync<RegistrySearchResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(searchPayload);
|
||||
Assert.Contains(searchPayload!.Items, item => item.Repository == "library/nginx");
|
||||
|
||||
var digestResponse = await client.GetAsync(
|
||||
"/api/v1/registries/images/digests?repository=library/nginx",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, digestResponse.StatusCode);
|
||||
|
||||
var digestPayload = await digestResponse.Content.ReadFromJsonAsync<RegistryDigestResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(digestPayload);
|
||||
Assert.Equal("library/nginx", digestPayload!.Repository);
|
||||
Assert.NotEmpty(digestPayload.Digests);
|
||||
Assert.All(digestPayload.Digests, item => Assert.StartsWith("sha256:", item.Digest, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UnknownRepositoryDigest_IsDeterministicAcrossRequests()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight();
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var first = await client.GetFromJsonAsync<RegistryDigestResponse>(
|
||||
"/api/v1/registries/images/digests?repository=example/custom-service",
|
||||
TestContext.Current.CancellationToken);
|
||||
var second = await client.GetFromJsonAsync<RegistryDigestResponse>(
|
||||
"/api/v1/registries/images/digests?repository=example/custom-service",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(first!.Digests[0].Digest, second!.Digests[0].Digest);
|
||||
}
|
||||
|
||||
public sealed class RegistrySearchResponse
|
||||
{
|
||||
public RegistryImageDto[] Items { get; set; } = [];
|
||||
public int TotalCount { get; set; }
|
||||
public string? RegistryId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegistryDigestResponse
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Repository { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public RegistryDigestEntry[] Digests { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RegistryImageDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Repository { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public RegistryDigestEntry[] Digests { get; set; } = [];
|
||||
public string LastPushed { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RegistryDigestEntry
|
||||
{
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
public string PushedAt { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user