Complete release compatibility and host inventory sprints

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260323_001-TASK-002 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: deployment monitoring compatibility endpoints under `/api/v1/release-orchestrator/deployments/*` were verified as implemented and reachable. |
| SPRINT_20260323_001-TASK-003 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: evidence compatibility endpoints now verify hashes against deterministic raw payloads and export stable offline bundles. |
| SPRINT_20260323_001-TASK-005 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: dashboard approval/rejection endpoints now persist in-memory promotion decisions per app instance for Console compatibility flows. |
| U-002-ORCH-DEADLETTER | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: add/fix deadletter API behavior used by console actions (including export route) and validate local setup usability paths. |
| AUDIT-0425-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.JobEngine.WebService. |
| AUDIT-0425-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.JobEngine.WebService. |

View File

@@ -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,

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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" />

View File

@@ -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`. |

View File

@@ -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;
}
}

View File

@@ -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). |

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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()
{

View File

@@ -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()}";
}
}

View File

@@ -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;
}
}